Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .brv/context-tree/facts/project/pr_push_outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: PR Push Outcome
summary: The remaining .brv memory was committed as 7edf357 and pushed; the PR branch is clean and up to date with origin/feat/configurable-plugin-options.
tags: []
related: []
keywords: []
createdAt: '2026-04-25T13:12:20.630Z'
updatedAt: '2026-04-25T13:12:20.630Z'
---
## Reason
Capture durable outcome of pushing the remaining .brv memory to the PR branch

## Raw Concept
**Task:**
Document the PR push outcome for the generated .brv memory update

**Changes:**
- Committed the remaining .brv memory separately
- Pushed the commit to the PR branch
- Confirmed the branch is clean and up to date with origin/feat/configurable-plugin-options

**Flow:**
generate .brv memory -> commit separately -> push to PR branch -> verify branch state

**Timestamp:** 2026-04-25T13:12:16.090Z

**Author:** assistant

## Narrative
### Structure
This note records a completed PR maintenance action involving a generated .brv memory and the corresponding git push outcome.

### Dependencies
Depends on the PR branch tracking origin/feat/configurable-plugin-options.

### Highlights
The final repository state was reported as clean, with the branch synchronized to origin after pushing commit 7edf357.

### Examples
Use this as the reference for the successful push and verification of the PR branch state.

## Facts
- **brv_memory_commit_flow**: The remaining generated .brv memory was committed separately before pushing. [project]
- **commit_hash**: The added commit was 7edf357 docs: add brv memory for test pruning. [project]
- **pr_branch_status**: The PR branch is clean and up to date with origin/feat/configurable-plugin-options. [project]
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: PR 5 Default Plugin Options Follow-up
summary: 'PR #5 was opened from latest main to preserve default plugin options behavior when no options are provided; verification passed on test, typecheck, lint, format:check, and build.'
tags: []
related: [project_management/pull_requests/pr_3_plugin_configurable_options.md, project_management/pull_requests/pr_3_test_pruning.md]
keywords: []
createdAt: '2026-04-25T14:19:09.928Z'
updatedAt: '2026-04-25T14:19:09.928Z'
---
## Reason
Document the follow-up PR created after PR #3 was already merged

## Raw Concept
**Task:**
Document the default-options follow-up pull request and its verification outcome

**Changes:**
- Added a regression test to ensure the plugin still works with default options
- Moved the change to a fresh branch because PR #3 had already been merged
- Opened follow-up PR #5 from latest main

**Files:**
- README.md
- src/index.test.ts

**Flow:**
request -> confirm default behavior -> create regression coverage -> verify -> open follow-up PR

**Timestamp:** 2026-04-25

**Author:** Ian

## Narrative
### Structure
This entry captures the PR workflow outcome for a configurable plugin options change. The key point is that the plugin must continue to function when callers omit options, and the work was redirected into a follow-up PR after the original PR was already merged.

### Dependencies
Depends on the existing plugin defaults and the test suite used to verify behavior. The final validation included test, typecheck, lint, format check, and build steps.

### Highlights
The default-options regression already passed against the current implementation, so no production code change was needed. PR #5 was opened from latest main, and all verification commands succeeded.

### Examples
Example outcome: calling the plugin with undefined options should still expose the default tool and inject the default guidance.

## Facts
- **default_plugin_options_requirement**: The user requested that the plugin still work with defaults when no options are provided. [project]
- **pr_3_status**: PR #3 was already merged, so the new work was moved to a fresh branch based on latest origin/main. [project]
- **follow_up_pr**: A follow-up PR was opened at https://github.com/ian-pascoe/opencode-adaptive-thinking/pull/5. [project]
- **verification_commands**: Verification passed with pnpm test, pnpm typecheck, pnpm lint, pnpm format:check, and pnpm build. [project]
- **commits**: Two commits were recorded: b8158f0 docs: add brv memory for PR push outcome and 49c40de test: cover default plugin options. [project]
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: PR 6 replacement for default-options changes
summary: 'PR #6 replaced superseded PR #5 after rebuilding default-options changes from latest main on feat/default-options-regression; verification passed and PR #5 was closed.'
tags: []
related: [project_management/pull_requests/pr_5_default_plugin_options_follow_up.md]
keywords: []
createdAt: '2026-04-25T14:21:30.945Z'
updatedAt: '2026-04-25T14:21:30.945Z'
---
## Reason
Document the superseded PR workflow and the replacement PR outcome after rebuilding from latest main.

## Raw Concept
**Task:**
Rebuild and replace a merged pull request with a fresh PR from latest main.

**Changes:**
- Checked out latest main
- Recreated the default-options regression on a fresh branch
- Opened replacement PR #6
- Closed superseded PR #5

**Flow:**
detect merged PR -> checkout latest main -> recreate changes on new branch -> open replacement PR -> close superseded PR -> verify build

**Timestamp:** 2026-04-25T14:21:25.247Z

**Author:** Ian

## Narrative
### Structure
This entry captures a PR replacement workflow for default-plugin-options work, including the old PR, the replacement branch, and the final open/closed PR state.

### Dependencies
Depends on the upstream main branch being current before rebuilding the changes.

### Highlights
New PR #6 is the active PR, PR #5 is closed as superseded, and the replacement branch was pushed cleanly to origin.

### Examples
The outcome included a critique link for review: https://critique.work/v/3fb211c04c48f7694274d07512a7f318

## Facts
- **pr_5_status**: PR #5 was already merged, so the work was rebuilt from latest main on a new branch. [project]
- **replacement_branch**: The replacement branch is feat/default-options-regression. [project]
- **replacement_pr**: A new PR was opened at https://github.com/ian-pascoe/opencode-adaptive-thinking/pull/6. [project]
- **superseded_pr**: The superseded PR is https://github.com/ian-pascoe/opencode-adaptive-thinking/pull/5. [project]
- **verification_commands**: Verification completed successfully with pnpm test, pnpm typecheck, pnpm lint, pnpm format:check, and pnpm build. [project]
5 changes: 5 additions & 0 deletions .changeset/bounded-session-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-adaptive-thinking": patch
---

Bound adaptive-thinking session state with an LRU cache to prevent unbounded growth across long-running OpenCode processes.
46 changes: 46 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,39 @@ const setReasoningEffort = async (
toolName = "set_reasoning_effort",
) => plugin.tool![toolName]!.execute(args, context as never);

const touchTemporarySession = async (sessionID: string) => {
const { client, toolContext } = createClient(sessionID, [createMessage("medium")]);
const plugin = await AdaptiveThinkingPlugin({ client } as never);

await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext);

return { client, plugin };
};

describe("AdaptiveThinkingPlugin", () => {
test("uses defaults when no options are provided", async () => {
const sessionID = "default-options";
const { client, toolContext } = createClient(sessionID, [createMessage("medium")]);
const plugin = await AdaptiveThinkingPlugin({ client } as never);
const system: string[] = [];

await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext);
await plugin["experimental.chat.system.transform"]!(
{
sessionID,
model: { variants },
} as never,
{ system },
);

expect(plugin.tool?.set_reasoning_effort?.description).toBe("Set your reasoning effort");
expect(client.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: expect.objectContaining({ variant: "high" }) }),
);
expect(system[0]).toContain("You MUST manage reasoning effort actively");
expect(system[0]).toContain("set_reasoning_effort");
});

test("returns no hooks when disabled", async () => {
const { client } = createClient("disabled-plugin");
const plugin = await AdaptiveThinkingPlugin({ client } as never, { enabled: false });
Expand Down Expand Up @@ -194,6 +226,20 @@ describe("AdaptiveThinkingPlugin", () => {
});
});

test("evicts oldest session state when the cache is full", async () => {
const oldest = await touchTemporarySession("lru-oldest");

for (let i = 0; i < 500; i++) {
await touchTemporarySession(`lru-session-${i}`);
}

await oldest.plugin.event!({
event: { type: "session.idle", properties: { sessionID: "lru-oldest" } },
} as never);

expect(oldest.client.session.promptAsync).toHaveBeenCalledTimes(1);
});

test("does not reset persisted reasoning effort on idle", async () => {
const sessionID = "persisted-effort";
const { client, toolContext } = createClient(sessionID, [createMessage("medium")]);
Expand Down
82 changes: 61 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,43 @@ import { ConfigSchema } from "./config.js";

const z = tool.schema;
const serviceName = "opencode-adaptive-thinking";
const maxSessionStateSize = 500;

const state = {
currentVariant: new Map<string, string>(),
persistedVariant: new Map<string, string>(),
temporaryResetVariant: new Map<string, string>(),
type SessionState = {
currentVariant?: string;
persistedVariant?: string;
temporaryResetVariant?: string;
};

class SessionStateCache {
private readonly entries = new Map<string, SessionState>();

constructor(private readonly maxSize: number) {}

get(sessionID: string) {
const entry = this.entries.get(sessionID);
if (!entry) return;

this.entries.delete(sessionID);
this.entries.set(sessionID, entry);
return entry;
}

update(sessionID: string, update: (entry: SessionState) => void) {
const entry = this.get(sessionID) ?? {};
update(entry);
this.entries.set(sessionID, entry);

while (this.entries.size > this.maxSize) {
const oldestSessionID = this.entries.keys().next().value;
if (!oldestSessionID) break;
this.entries.delete(oldestSessionID);
}
}
}

const state = new SessionStateCache(maxSessionStateSize);

export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => {
type PromptAsyncOptions = Parameters<typeof client.session.promptAsync>[0];
type PromptAsyncBody = NonNullable<PromptAsyncOptions["body"]> & { variant: string };
Expand Down Expand Up @@ -184,9 +214,10 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => {
return `Invalid reasoning effort level: ${level}. Valid levels: ${validVariants.join(", ")}.`;
}

const sessionState = state.get(sessionID);
const resetVariant = persist
? undefined
: (state.persistedVariant.get(sessionID) ?? (await resolveCurrentVariant(sessionID)));
: (sessionState?.persistedVariant ?? (await resolveCurrentVariant(sessionID)));

const promptResponse = await sendVariantPrompt(
sessionID,
Expand All @@ -197,15 +228,17 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => {
return `Failed to set reasoning effort: ${JSON.stringify(promptResponse.error.data)}`;
}

state.currentVariant.set(sessionID, level);
if (persist) {
state.persistedVariant.set(sessionID, level);
state.temporaryResetVariant.delete(sessionID);
} else if (resetVariant && resetVariant !== level) {
state.temporaryResetVariant.set(sessionID, resetVariant);
} else {
state.temporaryResetVariant.delete(sessionID);
}
state.update(sessionID, (entry) => {
entry.currentVariant = level;
if (persist) {
entry.persistedVariant = level;
delete entry.temporaryResetVariant;
} else if (resetVariant && resetVariant !== level) {
entry.temporaryResetVariant = resetVariant;
} else {
delete entry.temporaryResetVariant;
}
});

return `Reasoning effort set to ${level}`;
},
Expand All @@ -214,7 +247,8 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => {
event: async ({ event }) => {
if (event.type === "session.idle") {
const sessionID = event.properties.sessionID;
const resetVariant = state.temporaryResetVariant.get(sessionID);
const sessionState = state.get(sessionID);
const resetVariant = sessionState?.temporaryResetVariant;
if (!resetVariant) return;

const promptResponse = await sendVariantPrompt(
Expand All @@ -233,8 +267,10 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => {
return;
}

state.currentVariant.set(sessionID, resetVariant);
state.temporaryResetVariant.delete(sessionID);
state.update(sessionID, (entry) => {
entry.currentVariant = resetVariant;
delete entry.temporaryResetVariant;
});
return;
}
},
Expand All @@ -244,11 +280,15 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => {
const variants = await resolveValidVariants(sessionID, model as Model);
if (variants.length === 0) return;

let variant = state.currentVariant.get(sessionID) ?? state.persistedVariant.get(sessionID);
const sessionState = state.get(sessionID);
let variant = sessionState?.currentVariant ?? sessionState?.persistedVariant;
if (!variant) {
variant = await resolveCurrentVariant(sessionID);
if (variant && variants.includes(variant)) {
state.currentVariant.set(sessionID, variant);
const resolvedVariant = await resolveCurrentVariant(sessionID);
if (resolvedVariant && variants.includes(resolvedVariant)) {
variant = resolvedVariant;
state.update(sessionID, (entry) => {
entry.currentVariant = resolvedVariant;
});
}
}

Expand Down
Loading