From 8a917f8e1e6615259e3e8f21cda082b90155b118 Mon Sep 17 00:00:00 2001 From: Daniel Saldarriaga Date: Thu, 20 Aug 2026 20:21:38 +0200 Subject: [PATCH] fix: keep state mutations on one file --- CONTRIBUTING.md | 4 +- dist/server.js | 27 +++---- package.json | 4 +- src/state.ts | 19 +++-- test/server-v2.test.ts | 76 +++++++++++------- test/server.test.ts | 172 ++++++++++++++++++++++------------------- test/state.test.ts | 18 +++++ 7 files changed, 182 insertions(+), 138 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89d8941..c709ede 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,8 +19,8 @@ Useful scripts: | Script | What it does | | --- | --- | -| `bun run test` | Run the unit test suite serially | -| `bun run test:coverage` | Run the serial test suite with a coverage report | +| `bun run test` | Run the unit test suite | +| `bun run test:coverage` | Run the test suite with a coverage report | | `bun run lint` | ESLint over the repo | | `bun run typecheck` | TypeScript `--noEmit` check | | `bun run build` | Bundle `src/server.ts` into `dist/` | diff --git a/dist/server.js b/dist/server.js index f70626d..2fbca64 100644 --- a/dist/server.js +++ b/dist/server.js @@ -242,8 +242,7 @@ function parseStateText(raw, file) { function decodeState(value) { return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause }))); } -function readStateEffect() { - const file = statePath(); +function readStateEffect(file = statePath()) { return Effect.tryPromise({ try: () => readFile(file, "utf8"), catch: (cause) => new StateReadError({ cause }) @@ -252,10 +251,9 @@ function readStateEffect() { catch: (cause) => new StateDecodeError({ cause }) })), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error))); } -function writeStateEffect(state) { +function writeStateEffect(state, file = statePath()) { return Effect.tryPromise({ try: async () => { - const file = statePath(); await mkdir(dirname2(file), { recursive: true, mode: 448 }); await atomicWriteFile(file, JSON.stringify(state, null, 2) + ` `); @@ -277,15 +275,18 @@ function enqueueMutation(operation) { return current; } async function mutate(fn) { - return enqueueMutation(() => Effect.runPromise(Effect.gen(function* () { - const state = yield* readStateEffect(); - const result = yield* Effect.tryPromise({ - try: () => Promise.resolve(fn(state)), - catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) - }); - yield* writeStateEffect(state); - return result; - }))); + return enqueueMutation(() => { + const file = statePath(); + return Effect.runPromise(Effect.gen(function* () { + const state = yield* readStateEffect(file); + const result = yield* Effect.tryPromise({ + try: () => Promise.resolve(fn(state)), + catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) + }); + yield* writeStateEffect(state, file); + return result; + })); + }); } function validateObjective(objective) { const value = objective.trim(); diff --git a/package.json b/package.json index 0cc5913..f515ffa 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "ci:version": "bun scripts/resolve-ci-version.ts", "lint": "eslint .", "pack:dry-run": "npm pack --dry-run", - "test": "bun test --concurrent --max-concurrency 1", - "test:coverage": "bun test --concurrent --max-concurrency 1 --coverage", + "test": "bun test", + "test:coverage": "bun test --coverage", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run test && bun run build" }, diff --git a/src/state.ts b/src/state.ts index bb810ec..63b68c2 100644 --- a/src/state.ts +++ b/src/state.ts @@ -320,8 +320,7 @@ function decodeState(value: unknown) { ) } -function readStateEffect() { - const file = statePath() +function readStateEffect(file = statePath()) { return Effect.tryPromise({ try: () => readFile(file, "utf8"), catch: (cause) => new StateReadError({ cause }), @@ -339,10 +338,9 @@ function readStateEffect() { ) } -function writeStateEffect(state: State) { +function writeStateEffect(state: State, file = statePath()) { return Effect.tryPromise({ try: async () => { - const file = statePath() await mkdir(dirname(file), { recursive: true, mode: 0o700 }) // atomicWriteFile writes to a same-directory temp file, fsyncs it, then // renames it into place: the final path is only ever replaced by a @@ -387,19 +385,20 @@ function enqueueMutation(operation: () => Promise) { } async function mutate(fn: (state: State) => T | Promise) { - return enqueueMutation(() => - Effect.runPromise( + return enqueueMutation(() => { + const file = statePath() + return Effect.runPromise( Effect.gen(function* () { - const state = yield* readStateEffect() + const state = yield* readStateEffect(file) const result = yield* Effect.tryPromise({ try: () => Promise.resolve(fn(state)), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }) - yield* writeStateEffect(state) + yield* writeStateEffect(state, file) return result }), - ), - ) + ) + }) } export function validateObjective(objective: string) { diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 0151ff2..7103ed2 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -180,6 +180,21 @@ async function createGoalViaV2Tool(mock: MockContext, objective: string, agent = } let dir = "" +const setupDisposers: Array<() => void | Promise> = [] + +async function setupPlugin(...args: Parameters) { + const cleanup = await plugin.setup(...args) + let disposed = false + const dispose = async () => { + if (disposed) return + disposed = true + const index = setupDisposers.indexOf(dispose) + if (index >= 0) setupDisposers.splice(index, 1) + await cleanup() + } + setupDisposers.push(dispose) + return dispose +} beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-v2-")) @@ -187,6 +202,7 @@ beforeEach(async () => { }) afterEach(async () => { + for (const dispose of setupDisposers.splice(0).reverse()) await dispose() delete process.env.OPENCODE_GOAL_STATE_PATH await rm(dir, { recursive: true, force: true }) }) @@ -199,7 +215,7 @@ test("default export exposes both V1 server and V2 setup", () => { test("V2 setup registers goal tools with JSON Schema inputs, codemode:false, and {content} executors", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) expect(mock.tools.map((tool) => tool.name).sort()).toEqual(TOOL_NAMES) @@ -230,7 +246,7 @@ test("V2 setup registers goal tools with JSON Schema inputs, codemode:false, and test("V2 list_all_goals returns goals from other sessions", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await goalTool(mock, "create_goal").execute( { objective: "first V2 session goal" }, toolContext("ses_first"), @@ -253,7 +269,7 @@ test("V2 list_all_goals returns goals from other sessions", async () => { test("V2 create_goal recovers from a zero-filled state file", async () => { await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "\u0000\u0000", "utf8") const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) const created = await createGoalViaV2Tool(mock, "recover V2 state") @@ -265,7 +281,7 @@ test("V2 create_goal recovers from a zero-filled state file", async () => { test("V2 create_goal reuses the same active objective without reinitializing state", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await goalTool(mock, "create_goal").execute( { objective: "finish V2 safely", token_budget: 100 }, toolContext(), @@ -292,7 +308,7 @@ test("V2 create_goal reuses the same active objective without reinitializing sta test("V2 setup registers the /goal command via command transform", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) const command = mock.commandDraft.get("goal") expect(command).toBeDefined() @@ -307,7 +323,7 @@ test("V2 setup registers the /goal command via command transform", async () => { test("V2 setup skips command registration when register_command is false", async () => { const mock = makeMockContext({ auto_continue: false, register_command: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) expect(mock.commandDraft.get("goal")).toBeUndefined() expect(mock.disposals).not.toContain("command.transform") @@ -318,7 +334,7 @@ test("V2 setup skips command registration when register_command is false", async test("V2 session context hook injects the goal-mode system reminder", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) const contextHook = mock.hooks["context"]! expect(contextHook).toBeTypeOf("function") @@ -336,7 +352,7 @@ test("V2 session context hook injects the goal-mode system reminder", async () = test("V2 setup registers tool execute hooks", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) expect(mock.hooks["execute.before"]).toBeTypeOf("function") expect(mock.hooks["execute.after"]).toBeTypeOf("function") @@ -355,7 +371,7 @@ test("V2 setup registers tool execute hooks", async () => { test("V2 events account usage and checkpoints from step/usage events", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "account usage from events") // Step events drive per-step token sums and checkpoints. @@ -426,7 +442,7 @@ test("V2 events account usage and checkpoints from step/usage events", async () test("V2 step accounting excludes steps observed before goal creation", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) mock.stream.push({ type: "session.step.ended", @@ -462,7 +478,7 @@ test("V2 step accounting excludes steps observed before goal creation", async () test("V2 step and session sources do not double-count when session usage arrives first", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "reconcile usage sources") mock.stream.push({ @@ -494,7 +510,7 @@ test("V2 step and session sources do not double-count when session usage arrives test("V2 failed steps account usage and replace stale assistant progress", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "survive a failed model step") mock.stream.push({ @@ -529,7 +545,7 @@ test("V2 failed steps account usage and replace stale assistant progress", async test("V2 idle event triggers auto-continue via ctx.session.prompt", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "auto-continue from idle events") mock.stream.push({ type: "session.idle", created: Date.now(), data: { sessionID: "ses_v2" } }) @@ -548,7 +564,7 @@ test("V2 idle event triggers auto-continue via ctx.session.prompt", async () => test("V2 idle continuation waits for a running child session", async () => { const mock = makeMockContext({ min_continue_interval_seconds: 1 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "wait for delegated work") mock.stream.push({ type: "session.created", created: 100, data: { sessionID: "child", parentID: "ses_v2" } }) @@ -566,7 +582,7 @@ test("V2 idle continuation waits for a running child session", async () => { test("V2 idle auto-continue is suppressed for plan-agent goals", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "plan-mode goal must stay paused", "plan") mock.stream.push({ type: "session.idle", created: Date.now(), data: { sessionID: "ses_v2" } }) @@ -582,7 +598,7 @@ test("V2 idle auto-continue is suppressed for plan-agent goals", async () => { test("V2 cleanup disposes registrations and stops the event consumer", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "cleanup lifecycle") mock.stream.end() @@ -604,7 +620,7 @@ test("V2 cleanup disposes registrations and stops the event consumer", async () test("V2 session.error schedules bounded recovery without a phantom failure", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_auto_turns: 5 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "recover from a transport error") mock.stream.push({ @@ -630,7 +646,7 @@ test("V2 idle after a started pending attempt counts one unresolved failure and max_auto_turns: 5, max_prompt_failures: 1, }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "detect a no response") mock.stream.push({ type: "session.idle", created: 1, data: { sessionID: "ses_v2" } }) @@ -659,7 +675,7 @@ test("V2 persists the attempt before the prompt resolves so a later busy can cor resolvePrompt = resolve }) } - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "correlate a racing busy") void mock.stream.push({ type: "session.idle", created: 1, data: { sessionID: "ses_v2" } }) @@ -686,7 +702,7 @@ test("V2 persists the attempt before the prompt resolves so a later busy can cor test("V2 retry status cancels scheduled transport recovery", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "let the native retry win") mock.stream.push({ @@ -706,7 +722,7 @@ test("V2 retry status cancels scheduled transport recovery", async () => { test("V2 successful tool progress cancels no-pending transport recovery", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "cancel recovery via tool progress") mock.stream.push({ @@ -755,7 +771,7 @@ test("V2 successful tool progress cancels no-pending transport recovery", async test("V2 assistant progress cancels no-pending transport recovery", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "cancel recovery via assistant progress") mock.stream.push({ @@ -789,7 +805,7 @@ test("V2 assistant progress cancels no-pending transport recovery", async () => test("V2 watchdog rescues a busy active goal without consuming auto-turn budgets", async () => { const mock = makeMockContext({ auto_continue: false, max_turn_time: 0.02, max_prompt_failures: 5, max_auto_turns: 1 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "watchdog should not eat the budget") mock.stream.push({ type: "session.status", created: Date.now(), data: { sessionID: "ses_v2", status: { type: "busy" } } }) @@ -807,7 +823,7 @@ test("V2 non-transport prompt errors do not count toward the ceiling or retry", mock.session.prompt = async () => { throw new Error("invalid provider configuration") } - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "non-transport must be ignored") mock.stream.push({ type: "session.idle", created: Date.now(), data: { sessionID: "ses_v2" } }) @@ -823,7 +839,7 @@ test("V2 non-transport prompt errors do not count toward the ceiling or retry", test("V2 a native retry status suppresses a later session.error until busy ends the episode", async () => { const mock = makeMockContext({ auto_continue: true, min_continue_interval_seconds: 0, max_prompt_failures: 3 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "native retry must win") // retry arrives before the transport error; the error is suppressed while @@ -857,7 +873,7 @@ test("V2 a native retry status suppresses a later session.error until busy ends test("V2 watchdog no-response counts a failure on idle even with auto_continue false", async () => { const mock = makeMockContext({ auto_continue: false, max_turn_time: 0.02, max_prompt_failures: 5, max_auto_turns: 1 }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "watchdog no response with auto-continue disabled") mock.stream.push({ type: "session.status", created: Date.now(), data: { sessionID: "ses_v2", status: { type: "busy" } } }) @@ -885,7 +901,7 @@ test("V2 watchdog no-response counts a failure on idle even with auto_continue f test("V2 delayed tool output from a prior turn cannot clear a newer pending attempt", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "correlate tool progress to the attempt") // The tool call starts while attempt A is pending; the before hook captures @@ -937,7 +953,7 @@ test("V2 dispose during an in-flight prompt rolls back the reserved attempt on r }) throw new Error("network down") } - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "dispose mid-flight rejection") void mock.stream.push({ type: "session.idle", created: 1, data: { sessionID: "ses_v2" } }) @@ -967,7 +983,7 @@ test("V2 commits an accepted prompt when its recovery timer is canceled in fligh resolvePrompt = resolve }) } - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "commit accepted recovery") mock.stream.push({ @@ -999,7 +1015,7 @@ test("V2 commits an accepted prompt when its recovery timer is canceled in fligh test("V2 completed tool failures do not clear retry state", async () => { const mock = makeMockContext({ auto_continue: false }) - const cleanup = await plugin.setup(mock as never) + const cleanup = await setupPlugin(mock as never) await createGoalViaV2Tool(mock, "keep failed tools from masking recovery") await reserveContinuation("ses_v2", 10, 0) diff --git a/test/server.test.ts b/test/server.test.ts index 791d0ec..8e89f8d 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -40,6 +40,15 @@ async function waitForContinuation(calls: unknown[]) { } let dir = "" +const serverDisposers: Array<() => Promise> = [] + +async function setupServer(...args: Parameters) { + const hooks = await plugin.server(...args) + serverDisposers.push(async () => { + await hooks.dispose?.() + }) + return hooks +} beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-")) @@ -47,13 +56,14 @@ beforeEach(async () => { }) afterEach(async () => { + for (const dispose of serverDisposers.splice(0).reverse()) await dispose() delete process.env.OPENCODE_GOAL_STATE_PATH await rm(dir, { recursive: true, force: true }) }) test("server plugin exposes Codex-style goal tools", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -99,7 +109,7 @@ test("server plugin exposes Codex-style goal tools", async () => { }) test("list_all_goals returns goals from other sessions", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, { auto_continue: false }, ) @@ -125,7 +135,7 @@ test("list_all_goals returns goals from other sessions", async () => { }) test("set goal lets the agent formulate the goal objective", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -148,7 +158,7 @@ test("set goal lets the agent formulate the goal objective", async () => { }) test("create_goal reuses the same active objective without mutating state", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, { auto_continue: false }, ) @@ -182,7 +192,7 @@ test("create_goal reuses the same active objective without mutating state", asyn }) test("create_goal starts a fresh goal when the matching prior goal is closed", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, { auto_continue: false }, ) @@ -202,7 +212,7 @@ test("create_goal starts a fresh goal when the matching prior goal is closed", a }) test("concurrent matching create_goal calls converge on one goal", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, { auto_continue: false }, ) @@ -219,7 +229,7 @@ test("concurrent matching create_goal calls converge on one goal", async () => { }) test("duplicate limited goals retain the safety stop notice", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, { auto_continue: false }, ) @@ -235,7 +245,7 @@ test("duplicate limited goals retain the safety stop notice", async () => { }) test("server plugin registers goal as a desktop/web command by default", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -266,7 +276,7 @@ test("server plugin registers goal as a desktop/web command by default", async ( test("system transform is byte-stable across the complete goal lifecycle", async () => { setSystemTime(new Date(100_000)) - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -451,7 +461,7 @@ OpenCode goal mode policy: }) test("compaction autocontinue is disabled while a goal is active", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -472,7 +482,7 @@ test("compaction autocontinue is disabled while a goal is active", async () => { }) test("goal objective can be edited and history can be reported", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -500,7 +510,7 @@ test("goal objective can be edited and history can be reported", async () => { }) test("goal status tool pauses and resumes a goal", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -525,7 +535,7 @@ test("goal status tool pauses and resumes a goal", async () => { }) test("server plugin does not overwrite an existing goal command", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -551,7 +561,7 @@ test("server plugin does not overwrite an existing goal command", async () => { }) test("server plugin can disable desktop/web command registration", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -571,7 +581,7 @@ test("server plugin can disable desktop/web command registration", async () => { }) test("update goal can close as unmet with a blocker", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -597,7 +607,7 @@ test("update goal can close as unmet with a blocker", async () => { }) test("message transform prefers exact step token usage", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -640,7 +650,7 @@ test("message transform prefers exact step token usage", async () => { }) test("message transform excludes session usage observed before goal work", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { promptAsync: async () => {} } } } as never, { auto_continue: false }, ) @@ -675,7 +685,7 @@ test("message transform excludes session usage observed before goal work", async test("per-prompt chat hook recovers from an empty state file", async () => { await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "", "utf8") - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -692,7 +702,7 @@ test("per-prompt chat hook recovers from an empty state file", async () => { }) test("message transform records assistant checkpoints", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -725,7 +735,7 @@ test("message transform records assistant checkpoints", async () => { test("compaction hook preserves active goal context", async () => { setSystemTime(new Date(100_000)) - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -775,7 +785,7 @@ Preserve the goal objective, status, elapsed time, budget usage, latest checkpoi test("idle event auto-continues active goals when enabled", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -799,7 +809,7 @@ test("idle event auto-continues active goals when enabled", async () => { test("session status idle event auto-continues active goals", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -822,7 +832,7 @@ test("session status idle event auto-continues active goals", async () => { test("turn watchdog retries a busy active goal without consuming continuation budgets", async () => { const calls: { body?: { agent?: string; parts?: { text?: string }[] } }[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -879,7 +889,7 @@ test("turn watchdog retries a busy active goal without consuming continuation bu test("turn watchdog resets when another busy turn starts", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -910,7 +920,7 @@ test("turn watchdog resets when another busy turn starts", async () => { test("turn watchdog cancels on idle, retry, deletion, and dispose", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -961,7 +971,7 @@ test("turn watchdog cancels on idle, retry, deletion, and dispose", async () => test("turn watchdog does not inject while tasks are active, the goal is paused, or the turn is restricted", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1027,7 +1037,7 @@ test("turn watchdog does not inject while tasks are active, the goal is paused, test("turn watchdog transport failures share the prompt-failure ceiling without charging auto-turns", async () => { const logs: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { app: { log: async (input: unknown) => logs.push(input) }, @@ -1080,7 +1090,7 @@ test("turn watchdog transport failures share the prompt-failure ceiling without test("running task defers idle auto-continue", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1111,7 +1121,7 @@ test("running task defers idle auto-continue", async () => { test("running task deferral does not record repeated assistant messages as no-progress", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1155,7 +1165,7 @@ test("running task deferral does not record repeated assistant messages as no-pr }) test("low-output tool-call messages do not pause an active goal without continuations", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1206,7 +1216,7 @@ test("auto-continue pauses only after a low-progress continuation turn", async ( { type: "step-finish", tokens: { input: 10, output: 200 } }, ], } - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1255,7 +1265,7 @@ test("auto-continue pauses only after a low-progress continuation turn", async ( test("terminal task waits for orchestrator assistant turn before goal continuation", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1300,7 +1310,7 @@ test("terminal task waits for orchestrator assistant turn before goal continuati test("terminal-only task output defers until orchestrator reconciles it", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1353,7 +1363,7 @@ test("terminal-only task output defers until orchestrator reconciles it", async test("synthetic terminal task message defers until orchestrator reconciles it", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1391,7 +1401,7 @@ test("synthetic terminal task message defers until orchestrator reconciles it", test("live child session status blocks goal continuation when task launch was missed", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1416,7 +1426,7 @@ test("live child session status blocks goal continuation when task launch was mi test("idle live child session uses bounded deferral when task launch was missed", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1443,7 +1453,7 @@ test("idle live child session uses bounded deferral when task launch was missed" test("idle live child bounded retry does not inject while parent session is busy", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1479,7 +1489,7 @@ test("idle live child bounded retry does not inject while parent session is busy test("tracked running child absent from live children stops blocking after grace period", async () => { const calls: unknown[] = [] let children = [{ id: "task_1" }] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1510,7 +1520,7 @@ test("tracked running child absent from live children stops blocking after grace test("task deferral can be disabled with config", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1537,7 +1547,7 @@ test("task deferral can be disabled with config", async () => { test("auto-continue failures pause after configured retry limit", async () => { const logs: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { app: { @@ -1566,7 +1576,7 @@ test("auto-continue failures pause after configured retry limit", async () => { test("set_goal from the plan agent records a paused goal instead of an active one", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1596,7 +1606,7 @@ test("set_goal from the plan agent records a paused goal instead of an active on }) test("create_goal from the plan agent records a paused goal", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1626,7 +1636,7 @@ test("create_goal from the plan agent records a paused goal", async () => { }) test("plan-created goal cannot resume from plan but resumes from build", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1659,7 +1669,7 @@ test("plan-created goal cannot resume from plan but resumes from build", async ( }) test("update_goal_objective cannot activate a goal from the plan agent", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1689,7 +1699,7 @@ test("update_goal_objective cannot activate a goal from the plan agent", async ( test("idle continuation is blocked when the latest assistant turn ran under plan", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1726,7 +1736,7 @@ test("idle continuation is blocked when the latest assistant turn ran under plan test("build resume of a plan-created goal restores auto-continue pinned to build", async () => { const calls: { body?: { agent?: string } }[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1760,7 +1770,7 @@ test("build resume of a plan-created goal restores auto-continue pinned to build test("idle continuation is suppressed and pauses the goal after a plan-mode prompt", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1793,7 +1803,7 @@ test("idle continuation is suppressed and pauses the goal after a plan-mode prom test("auto-continue pins the continuation prompt to the recorded agent", async () => { const calls: { body?: { agent?: string } }[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1819,7 +1829,7 @@ test("auto-continue pins the continuation prompt to the recorded agent", async ( }) test("system reminder remains invariant after a plan-mode prompt", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1853,7 +1863,7 @@ test("system reminder remains invariant after a plan-mode prompt", async () => { }) test("allow_goal_execution_from_plan restores active goal creation from plan", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1876,7 +1886,7 @@ test("allow_goal_execution_from_plan restores active goal creation from plan", a }) test("restricted_agents option extends plan-mode protection to custom agents", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1901,7 +1911,7 @@ test("restricted_agents option extends plan-mode protection to custom agents", a test("idle handler skips overlapping continuations for the same session", async () => { let release: (() => void) | undefined const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -1931,7 +1941,7 @@ test("idle handler skips overlapping continuations for the same session", async test("auto-continue retries are bounded: three failed attempts, no fourth", async () => { const logs: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { app: { log: async (input: unknown) => logs.push(input) }, @@ -1962,7 +1972,7 @@ test("auto-continue retries are bounded: three failed attempts, no fourth", asyn test("failed continuation retries wait for the configured minimum interval", async () => { const logs: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { app: { log: async (input: unknown) => logs.push(input) }, @@ -2005,7 +2015,7 @@ test("recognized transport error strings accumulate as continuation failures", a "Provider response headers timed out after 10000ms", ] for (const [index, message] of errors.entries()) { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2038,7 +2048,7 @@ test("recognized transport error strings accumulate as continuation failures", a }) test("failed tool output does not reset prompt failures; successful tool output does", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2075,7 +2085,7 @@ test("failed tool output does not reset prompt failures; successful tool output test("duplicate idle events before any busy never count a failure or send a duplicate", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2112,7 +2122,7 @@ test("duplicate idle events before any busy never count a failure or send a dupl test("paired idle events after a busy count exactly one unresolved failure and pause at the ceiling", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2156,7 +2166,7 @@ test("paired idle events after a busy count exactly one unresolved failure and p }) test("concurrent session.error transport events count at most one failure per pending attempt", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2215,7 +2225,7 @@ test("concurrent session.error transport events count at most one failure per pe test("auto_continue false never schedules a retry after a transport event", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2252,7 +2262,7 @@ test("a repeated old assistant message cannot hide a no-response failure", async info: { id: "msg_old", role: "assistant", sessionID: "ses_old_message" }, parts: [{ type: "text", text: "Earlier progress" }], } - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2288,7 +2298,7 @@ test("a repeated old assistant message cannot hide a no-response failure", async test("non-transport prompt errors do not count toward the ceiling or auto-retry", async () => { const logs: unknown[] = [] let calls = 0 - const hooks = await plugin.server( + const hooks = await setupServer( { client: { app: { log: async (input: unknown) => logs.push(input) }, @@ -2321,7 +2331,7 @@ test("non-transport prompt errors do not count toward the ceiling or auto-retry" test("session.error without a pending attempt schedules recovery without a phantom failure", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2362,7 +2372,7 @@ test("session.error without a pending attempt schedules recovery without a phant test("restart resolves a persisted started pending attempt at the next idle", async () => { const firstCalls: unknown[] = [] - const hooks1 = await plugin.server( + const hooks1 = await setupServer( { client: { session: { @@ -2389,7 +2399,7 @@ test("restart resolves a persisted started pending attempt at the next idle", as // A fresh instance reads the same persisted state: the started=true pending // attempt must be resolvable by the next idle after the restart. const calls2: unknown[] = [] - const hooks2 = await plugin.server( + const hooks2 = await setupServer( { client: { session: { @@ -2415,7 +2425,7 @@ test("restart resolves a persisted started pending attempt at the next idle", as }) test("persisted started=false pending attempts go stale after restart", async () => { - const hooks1 = await plugin.server( + const hooks1 = await setupServer( { client: { session: { @@ -2451,7 +2461,7 @@ test("persisted started=false pending attempts go stale after restart", async () await hooks1.dispose?.() const calls: unknown[] = [] - const hooks2 = await plugin.server( + const hooks2 = await setupServer( { client: { session: { @@ -2477,7 +2487,7 @@ test("persisted started=false pending attempts go stale after restart", async () test("a locally delivered unstarted attempt never becomes a false no-response failure", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2521,7 +2531,7 @@ test("a locally delivered unstarted attempt never becomes a false no-response fa test("a built-in retry status cancels scheduled transport recovery", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2557,7 +2567,7 @@ test("a built-in retry status cancels scheduled transport recovery", async () => test("a native retry status suppresses a later session.error until busy or idle ends the episode", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2608,7 +2618,7 @@ test("a native retry status suppresses a later session.error until busy or idle test("an error during a native retry episode does not fail the pending attempt", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2660,7 +2670,7 @@ test("an error during a native retry episode does not fail the pending attempt", test("assistant progress cancels no-pending transport recovery", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2702,7 +2712,7 @@ test("assistant progress cancels no-pending transport recovery", async () => { test("successful tool progress cancels no-pending transport recovery", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2736,7 +2746,7 @@ test("successful tool progress cancels no-pending transport recovery", async () test("interrupted connection messages are not classified as transport recovery", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2768,7 +2778,7 @@ test("interrupted connection messages are not classified as transport recovery", }) test("tool progress honors completed states and never resets on failed or incomplete tools", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2826,7 +2836,7 @@ test("tool progress honors completed states and never resets on failed or incomp }) test("delayed tool output from a prior turn cannot clear a newer pending attempt", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2880,7 +2890,7 @@ test("delayed tool output from a prior turn cannot clear a newer pending attempt test("watchdog rescues at most once per busy episode", async () => { const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2922,7 +2932,7 @@ test("watchdog rescues at most once per busy episode", async () => { test("a busy that races prompt resolution correlates to the persisted attempt", async () => { let resolvePrompt: (() => void) | undefined const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -2968,7 +2978,7 @@ test("a busy that races prompt resolution correlates to the persisted attempt", test("dispose prevents an in-flight continuation from scheduling retries or committing turns", async () => { let resolvePrompt: (() => void) | undefined const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { @@ -3007,7 +3017,7 @@ test("dispose while a prompt is in flight rolls back on rejection without a fail let resolvePrompt: (() => void) | undefined const logs: unknown[] = [] const calls: unknown[] = [] - const hooks = await plugin.server( + const hooks = await setupServer( { client: { app: { log: async (input: unknown) => logs.push(input) }, @@ -3049,7 +3059,7 @@ test("dispose while a prompt is in flight rolls back on rejection without a fail }) test("the public goal tool result never exposes internal pending attempt fields", async () => { - const hooks = await plugin.server( + const hooks = await setupServer( { client: { session: { diff --git a/test/state.test.ts b/test/state.test.ts index 433b0c9..4ba8147 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -55,6 +55,24 @@ test("creates, reads, pauses, resumes, completes, and clears a goal", async () = expect(await getGoal("ses_1")).toBeNull() }) +test("a mutation writes back to the state path it read", async () => { + const firstPath = process.env.OPENCODE_GOAL_STATE_PATH! + const secondPath = join(dir, "other-goals.json") + await createGoal("ses_path", "original objective", null) + + const update = updateGoalObjective("ses_path", "updated objective") + queueMicrotask(() => { + process.env.OPENCODE_GOAL_STATE_PATH = secondPath + }) + await update + + process.env.OPENCODE_GOAL_STATE_PATH = firstPath + expect((await getGoal("ses_path"))?.objective).toBe("updated objective") + process.env.OPENCODE_GOAL_STATE_PATH = secondPath + expect(await getGoal("ses_path")).toBeNull() + process.env.OPENCODE_GOAL_STATE_PATH = firstPath +}) + test("lists public goals across sessions by most recent update", async () => { expect(await getAllGoals()).toEqual({ goals: [], total: 0, truncated: false })