From f6ad4bb7dae2f89e6eecbe3d778bb1a321cd4a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Thu, 8 Oct 2026 23:29:35 +0800 Subject: [PATCH 01/15] fix(session): keep interrupted turns recoverable without replaying completed work --- packages/cli/src/question/index.ts | 21 ++- packages/cli/src/session/prompt.ts | 16 +- packages/cli/src/tool/plan.ts | 1 + packages/cli/src/tool/question.ts | 1 + packages/cli/test/question/question.test.ts | 115 +++++++++++- .../cli/test/server/session-recovery.test.ts | 176 +++++++++++++++++- .../invalid-output-continuation.test.ts | 84 ++++++++- .../cli/test/session/tool-gate-cancel.test.ts | 20 +- packages/cli/test/tool/plan.test.ts | 23 +++ 9 files changed, 435 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/question/index.ts b/packages/cli/src/question/index.ts index 96208ac2ea..f011b23466 100644 --- a/packages/cli/src/question/index.ts +++ b/packages/cli/src/question/index.ts @@ -131,6 +131,7 @@ export interface Interface { sessionID: SessionID questions: ReadonlyArray tool?: Tool + abortSignal?: AbortSignal }) => Effect.Effect, RejectedError> readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray }) => Effect.Effect readonly reject: (requestID: QuestionID) => Effect.Effect @@ -169,8 +170,10 @@ export const layer = Layer.effect( sessionID: SessionID questions: ReadonlyArray tool?: Tool + abortSignal?: AbortSignal }) { const pending = (yield* InstanceState.get(state)).pending + if (input.abortSignal?.aborted) return yield* Effect.fail(new RejectedError()) const id = QuestionID.ascending() log.info("asking", { id, questions: input.questions.length }) @@ -182,8 +185,24 @@ export const layer = Layer.effect( tool: input.tool, }) pending.set(id, { info, deferred }) + const signal = input.abortSignal + const awaiting = signal + ? Effect.raceFirst( + Deferred.await(deferred), + Effect.callback, RejectedError>((resume) => { + // Reuse request ownership: a reply already in flight must win over a late abort. + const onAbort = () => resume(reject(id).pipe(Effect.andThen(Deferred.await(deferred)))) + if (signal.aborted) { + onAbort() + return Effect.void + } + signal.addEventListener("abort", onAbort, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", onAbort)) + }), + ) + : Deferred.await(deferred) return yield* Effect.ensuring( - bus.publish(Event.Asked, info).pipe(Effect.andThen(Deferred.await(deferred))), + bus.publish(Event.Asked, info).pipe(Effect.andThen(awaiting)), Effect.gen(function* () { // A caller interrupt (e.g. MCP cancellation) must also withdraw the UI. if (!pending.delete(id)) return diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 6dc6f051d2..80ba23659f 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -3503,15 +3503,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const isEmptyAssistantResidue = (assistant: MessageV2.Assistant, parts: readonly MessageV2.Part[]) => - assistant.role === "assistant" && !hasUsefulAssistantParts(parts) + assistant.structured === undefined && !assistant.summary && !hasUsefulAssistantParts(parts) /** - * Shared step-level "still incomplete / recoverable" predicate for `/recovery` - * listing and resume planning. - * tool-calls / length / missing finish / any error => recoverable; - * completed+stop/other without error => not. + * Provider stop alone is not a valid delivery: empty stops can resume from + * their parent even after completion was persisted, before classification. */ - const isRecoveryWorthyAssistant = (info: MessageV2.Assistant) => { + const isRecoveryWorthyAssistant = (info: MessageV2.Assistant, parts: readonly MessageV2.Part[]) => { + if (info.finish === "stop" && isEmptyAssistantResidue(info, parts)) return true if ( "completed" in info.time && !info.error && @@ -3530,7 +3529,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the * - Live empty shells (busy, no terminal marker) are skipped by default. * - `preserveError`: keep empty shells that carry `error` (recovery candidates / failure site). * - `force`: post-run ensuring sweep; still respects preserveError. - * Emptiness is parts-only; `error` is not useful parts but is a keep-for-recovery signal. + * Resolved structured output and summaries are retained even without useful parts. + * `error` is a keep-for-recovery signal, not useful content. */ const cleanupEmptyResidueAssistants = Effect.fn("SessionPrompt.cleanupEmptyResidueAssistants")(function* (input: { sessionID: SessionID @@ -3583,7 +3583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Only incomplete assistants are recovery candidates (shared with resume planning via // isRecoveryWorthyAssistant). error => not finished => recoverable; abandoned // completed+AbortedError remains retryable. - if (!isRecoveryWorthyAssistant(msg.info)) continue + if (!isRecoveryWorthyAssistant(msg.info, msg.parts)) continue const assistant = msg.info if (!msgs.some((parent) => parent.info.role === "user" && parent.info.id === assistant.parentID)) continue if (msgs.slice(index + 1).some((later) => later.info.role === "user" || later.info.role === "assistant")) continue diff --git a/packages/cli/src/tool/plan.ts b/packages/cli/src/tool/plan.ts index ce278dbbb5..51e8a073cb 100644 --- a/packages/cli/src/tool/plan.ts +++ b/packages/cli/src/tool/plan.ts @@ -41,6 +41,7 @@ export const PlanExitTool = Tool.define( const plan = path.relative(Instance.worktree, Session.plan(info)) const answers = yield* question.ask({ sessionID: ctx.sessionID, + abortSignal: ctx.abort, questions: [ { key: "plan_exit", diff --git a/packages/cli/src/tool/question.ts b/packages/cli/src/tool/question.ts index 129c53a4e8..267ae09fe6 100644 --- a/packages/cli/src/tool/question.ts +++ b/packages/cli/src/tool/question.ts @@ -47,6 +47,7 @@ export const QuestionTool = Tool.define; tool?: Question.Tool }) => @@ -467,3 +473,110 @@ test("pending question prevents instance reload", async () => { expect(await result).toBeInstanceOf(Question.RejectedError) }) + +const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const cancellationInput = { + sessionID: SessionID.make("ses_cancel"), + questions: [{ question: "Continue?", header: "Continue", options: [] }], +} + +const waitForListener = Effect.fn("QuestionTest.waitForListener")(function* (signal: AbortSignal) { + while (getEventListeners(signal, "abort").length === 0) yield* Effect.sleep("1 millis") +}) + +for (const when of ["before-ask", "during-asked"] as const) { + it.live(`pre-aborted and publication-window cancellation: ${when} [TP-SR-R21-25]`, () => + provideTmpdirInstance((directory) => + Effect.gen(function* () { + const question = yield* Question.Service + const controller = new AbortController() + const events: string[] = [] + const listener = (event: GlobalEvent) => { + if (event.directory !== directory || !event.payload.type.startsWith("question.")) return + events.push(event.payload.type) + if (event.payload.type === "question.asked") controller.abort() + } + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + if (when === "before-ask") controller.abort() + const exit = yield* question.ask({ ...cancellationInput, abortSignal: controller.signal }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + expect(JSON.stringify(exit)).toContain("QuestionRejectedError") + expect(yield* question.list()).toEqual([]) + expect(events).toEqual(when === "before-ask" ? [] : ["question.asked", "question.rejected"]) + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) + }).pipe(Effect.timeout("5 seconds")), + ), + ) +} + +for (const outcome of ["abort", "reply", "reject", "reply-race", "reject-race", "interrupt"] as const) { + it.live(`question cancellation ownership and listener cleanup: ${outcome} [TP-SR-R21-25]`, () => + provideTmpdirInstance((directory) => + Effect.gen(function* () { + const question = yield* Question.Service + const controller = new AbortController() + const events: string[] = [] + const listener = (event: GlobalEvent) => { + if (event.directory !== directory || !event.payload.type.startsWith("question.")) return + events.push(event.payload.type) + // Exercise late abort before reply/reject has finished resolving its Deferred. + if (outcome.endsWith("-race") && event.payload.type !== "question.asked") controller.abort() + } + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + const fiber = yield* question + .ask({ ...cancellationInput, abortSignal: controller.signal }) + .pipe(Effect.forkScoped) + yield* waitForListener(controller.signal) + const [request] = yield* question.list() + expect(request).toBeDefined() + expect(getEventListeners(controller.signal, "abort")).toHaveLength(1) + if (outcome === "abort") controller.abort() + if (outcome.startsWith("reply")) yield* question.reply({ requestID: request.id, answers: [["Yes"]] }) + if (outcome.startsWith("reject")) yield* question.reject(request.id) + if (outcome === "interrupt") yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(outcome.startsWith("reply")) + if (Exit.isSuccess(exit)) expect(exit.value).toEqual([["Yes"]]) + if (outcome === "abort" || outcome.startsWith("reject")) expect(JSON.stringify(exit)).toContain("QuestionRejectedError") + expect(yield* question.list()).toEqual([]) + expect(events).toEqual(["question.asked", outcome.startsWith("reply") ? "question.replied" : "question.rejected"]) + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) + controller.abort() + expect(events).toHaveLength(2) + }).pipe(Effect.timeout("5 seconds")), + ), + ) +} + +it.live("aborting one question preserves same-session and cross-session requests [TP-SR-R21-25]", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const question = yield* Question.Service + const controllers = [new AbortController(), new AbortController(), new AbortController()] + const fibers = yield* Effect.forEach(controllers, (controller, index) => + question + .ask({ + ...cancellationInput, + sessionID: index === 2 ? SessionID.make("ses_other") : cancellationInput.sessionID, + tool: { messageID: MessageID.make(`msg_question_${index}`), callID: `question-${index}` }, + abortSignal: controller.signal, + }) + .pipe(Effect.forkScoped), + ) + yield* Effect.forEach(controllers, (controller) => waitForListener(controller.signal)) + const requests = yield* question.list() + expect(requests).toHaveLength(3) + controllers[0].abort() + expect(Exit.isFailure(yield* Fiber.await(fibers[0]))).toBe(true) + const remaining = yield* question.list() + expect(remaining.map((request) => request.tool?.callID)).toEqual(["question-1", "question-2"]) + yield* Effect.forEach(remaining, (request) => question.reply({ requestID: request.id, answers: [["Yes"]] })) + expect(yield* Fiber.join(fibers[1])).toEqual([["Yes"]]) + expect(yield* Fiber.join(fibers[2])).toEqual([["Yes"]]) + expect(yield* question.list()).toEqual([]) + for (const controller of controllers) expect(getEventListeners(controller.signal, "abort")).toHaveLength(0) + }).pipe(Effect.timeout("5 seconds")), + ), +) diff --git a/packages/cli/test/server/session-recovery.test.ts b/packages/cli/test/server/session-recovery.test.ts index ac0204c9c5..829a38efdc 100644 --- a/packages/cli/test/server/session-recovery.test.ts +++ b/packages/cli/test/server/session-recovery.test.ts @@ -12,6 +12,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Log } from "../../src/util" import { Bus } from "../../src/bus" import { tmpdir } from "../fixture/fixture" +import { startScriptedLLMServer, textStopResponse } from "../lib/scripted-llm-server" void Log.init({ print: false }) @@ -160,10 +161,20 @@ test("SDK serializes resume titleLocale in the query string", async () => { expect(captured!.body).toBeNull() }) -// [TP-SR-R21-07] Recovery predicate: completed+tool-calls / completed+length / no completed are candidates; -// completed+stop / completed+other are not. +const finalDeliveries = [ + { structured: { answer: 4 } }, + { structured: false }, + { structured: 0 }, + { structured: "" }, + { structured: null }, + { summary: true }, +] + +// [TP-SR-R21-07] describe("recovery candidate predicate", () => { - async function setupAssistant(overrides: Partial<{ finish: string; completed: boolean; error: boolean }>) { + async function setupAssistant(overrides: Partial<{ + finish: string; completed: boolean; error: boolean; text: string; structured: unknown; summary: boolean + }>) { await using tmp = await tmpdir({ git: true }) // Await so `await using` disposal (rm -rf tmpdir) cannot race provide's setup writes. return await Instance.provide({ @@ -196,11 +207,23 @@ describe("recovery candidate predicate", () => { : { created: Date.now() }, ...(overrides.finish ? { finish: overrides.finish as "stop" | "length" | "tool-calls" | "other" } : {}), ...(overrides.error ? { error: { name: "APIError", data: { message: "model unavailable", statusCode: 503, isRetryable: true } } } : {}), + ...(overrides.structured !== undefined ? { structured: overrides.structured } : {}), + ...(overrides.summary ? { summary: overrides.summary } : {}), } as Parameters[0]) + if (overrides.text) yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: assistant.id, + type: "text", text: overrides.text, + }) const candidates = yield* SessionPrompt.Service.use((svc) => svc.recovery({ sessionID: session.id, agentID: "main" }), ) - return { candidates, assistantId: assistant.id } + const rejected = candidates.length === 0 + ? yield* Effect.promise(() => Promise.resolve(Server.Default().app.request( + `/session/${session.id}/resume?directory=${encodeURIComponent(tmp.path)}`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, + ))) + : undefined + return { candidates, assistantId: assistant.id, resumeStatus: rejected?.status } })), }) } @@ -223,11 +246,27 @@ describe("recovery candidate predicate", () => { expect(result.candidates.length).toBe(1) }) - test("completed + stop → NOT candidate", async () => { - const result = await setupAssistant({ completed: true, finish: "stop" }) + test("completed + stop with final text → NOT candidate", async () => { + const result = await setupAssistant({ completed: true, finish: "stop", text: "final answer" }) expect(result.candidates.length).toBe(0) }) + for (const completed of [false, true]) { + for (const delivery of finalDeliveries) { + test(`stop + ${JSON.stringify(delivery)} completed=${completed} is not resumed`, async () => { + const result = await setupAssistant({ finish: "stop", completed, ...delivery }) + expect(result.candidates).toEqual([]) + expect(result.resumeStatus).toBe(404) + }) + } + } + + test("completed + content-filter with no output → NOT candidate", async () => { + const result = await setupAssistant({ completed: true, finish: "content-filter" }) + expect(result.candidates).toEqual([]) + expect(result.resumeStatus).toBe(404) + }) + test("completed + other → NOT candidate", async () => { const result = await setupAssistant({ completed: true, finish: "other" }) expect(result.candidates.length).toBe(0) @@ -246,6 +285,131 @@ describe("recovery candidate predicate", () => { }) }) +// [TP-SR-R21-07] +describe("empty stop recovery matrix", () => { + for (const output of ["empty", "placeholders", "text"] as const) { + const content = output === "text" + for (const completed of [false, true]) { + for (const error of [false, true]) { + const label = `content=${content} completed=${completed} error=${error}${output === "placeholders" ? " placeholders" : ""}` + test(label, async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: textStopResponse("recovered answer") }]) + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) + const result = await Instance.provide({ + directory: tmp.path, + fn: () => AppRuntime.runPromise(Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "empty-stop-matrix" }) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), role: "user", sessionID: session.id, agent: "build", + model: { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") }, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: user.id, + type: "text", text: "Answer my question.", + }) + const assistantInfo = { + role: "assistant" as const, parentID: user.id, sessionID: session.id, + mode: "build", agent: "build", path: { cwd: tmp.path, root: tmp.path }, cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelID.make("qwen-plus"), providerID: ProviderID.make("alibaba"), + time: { created: Date.now(), ...(completed ? { completed: Date.now() } : {}) }, + finish: "stop", + ...(error ? { error: { name: "APIError" as const, data: { message: "model unavailable", statusCode: 503, isRetryable: true } } } : {}), + } + for (const delivery of finalDeliveries) yield* sessions.updateMessage({ + ...assistantInfo, id: MessageID.ascending(), error: undefined, + time: { created: Date.now(), completed: Date.now() }, ...delivery, + }) + const reasoning = yield* sessions.updateMessage({ + ...assistantInfo, id: MessageID.ascending(), error: undefined, + modelID: ModelID.make("gpt-5.5"), time: { created: Date.now(), completed: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: reasoning.id, + type: "reasoning", text: "reasoned answer", time: { start: Date.now(), end: Date.now() }, + }) + const beforeDeliveries = (yield* sessions.messages({ sessionID: session.id, agentID: "main" })) + .filter((message) => message.info.role === "assistant") + const created = Date.now() + 1 + const assistant = yield* sessions.updateMessage({ + ...assistantInfo, id: MessageID.ascending(), + time: { created, ...(completed ? { completed: created } : {}) }, + }) + if (content) yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: assistant.id, + type: "text", text: "ordinary answer", + }) + if (output === "placeholders") { + for (const placeholder of [ + { text: " \n\t" }, + { text: "synthetic placeholder", synthetic: true }, + { text: "ignored placeholder", ignored: true }, + ]) yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: assistant.id, + type: "text", ...placeholder, + }) + } + let plan: { action: string; assistantMessageID?: string; parentMessageID?: string } | undefined + ResumeTestHooks.onPlanResolved = (value) => { plan = value } + const app = Server.Default().app + const query = `?directory=${encodeURIComponent(tmp.path)}` + const listed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/recovery${query}`))) + const candidates = yield* Effect.promise(() => listed.json()) + const resumed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/resume${query}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }))) + for (let attempt = 0; resumed.status === 202 && attempt < 100; attempt++) { + const messages = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + if (messages.some((message) => message.info.role === "assistant" && message.info.time.completed && !message.info.error && message.parts.some((part) => part.type === "text" && part.text === "recovered answer"))) break + yield* Effect.sleep("50 millis") + } + const after = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + return { + recoveryStatus: listed.status, candidates, resumeStatus: resumed.status, + plan, providerCalls: stub.captures.length, + shellRemoved: !after.some((message) => message.info.id === assistant.id), + recovered: after.find((message) => message.info.role === "assistant" && message.info.time.completed && !message.info.error && message.parts.some((part) => part.type === "text" && part.text === "recovered answer")), + beforeDeliveries, + afterDeliveries: after.filter((message) => beforeDeliveries.some((before) => before.info.id === message.info.id)), + users: after.filter((message) => message.info.role === "user").map((message) => message.info.id), + userID: user.id, + } + })), + }) + const shouldRecover = !content || error + expect(result.users).toEqual([result.userID]) + expect(result.afterDeliveries).toEqual(result.beforeDeliveries) + expect(result.recoveryStatus).toBe(200) + expect(result.candidates.length).toBe(shouldRecover ? 1 : 0) + expect(result.resumeStatus).toBe(shouldRecover ? 202 : 404) + if (shouldRecover) { + expect(result.plan?.action).toBe(content ? "tool-resume" : "user-resume") + expect(result.recovered?.info).toMatchObject({ role: "assistant", parentID: result.userID }) + expect(result.providerCalls).toBe(1) + expect(result.shellRemoved).toBe(!content) + } else { + expect(result.shellRemoved).toBe(false) + expect(result.providerCalls).toBe(0) + } + } finally { + ResumeTestHooks.onPlanResolved = undefined + await Instance.disposeAll() + await stub.stop() + } + }, 30_000) + } + } + } +}) + // [TP-SR-R21-08] model params: modelProviderID / modelID must be provided together. test("resume with only modelProviderID returns 400", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/cli/test/session/invalid-output-continuation.test.ts b/packages/cli/test/session/invalid-output-continuation.test.ts index 2472eb78ea..d1b9473b22 100644 --- a/packages/cli/test/session/invalid-output-continuation.test.ts +++ b/packages/cli/test/session/invalid-output-continuation.test.ts @@ -9,8 +9,10 @@ */ import path from "path" -import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { Effect, Fiber, Layer } from "effect" +import { SessionPrefixSnapshot } from "../../src/session/prefix-snapshot" +import { Server } from "../../src/server/server" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" @@ -88,6 +90,75 @@ function writeGPTConfig(dir: string, origin: string) { } describe("invalid-output continuation — integration", () => { + // [TP-SR-R21-07] + test("cancel after empty stop persistence remains recoverable", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: emptyStopResponse() }, { lines: textStopResponse("recovered answer") }]) + const entered = Promise.withResolvers() + // Completion is durable here, but output validity has not been classified. + const advance = spyOn(SessionPrefixSnapshot, "advance").mockImplementation(() => + Effect.sync(entered.resolve).pipe(Effect.andThen(Effect.never)), + ) + try { + await writeConfig(tmp.path, stub.origin) + const result = await Instance.provide({ + directory: tmp.path, + fn: () => run(Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "empty-stop-cancel-window" }) + const fiber = yield* prompt.prompt({ + sessionID: session.id, agent: "build", parts: [{ type: "text", text: "Answer my question." }], + }).pipe(Effect.exit, Effect.forkChild) + yield* Effect.promise(() => entered.promise).pipe(Effect.timeout("10 seconds")) + expect(advance).toHaveBeenCalledTimes(1) + const atBarrier = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + yield* prompt.cancel(session.id) + yield* Fiber.join(fiber) + advance.mockRestore() + const afterCancel = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + const query = `?directory=${encodeURIComponent(tmp.path)}` + const app = Server.Default().app + const listed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/recovery${query}`))) + const candidates = yield* Effect.promise(() => listed.json()) + const resumed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/resume${query}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }))) + for (let attempt = 0; resumed.status === 202 && attempt < 100; attempt++) { + const messages = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + if (messages.some((message) => message.info.role === "assistant" && message.info.time.completed && !message.info.error && message.parts.some((part) => part.type === "text" && part.text === "recovered answer"))) break + yield* Effect.sleep("50 millis") + } + const afterResume = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + return { atBarrier, afterCancel, afterResume, recoveryStatus: listed.status, candidates, resumeStatus: resumed.status, providerCalls: stub.captures.length } + })), + }) + expect(result.afterCancel).toEqual(result.atBarrier) + const assistant = result.afterCancel.find((message) => message.info.role === "assistant") + expect(assistant?.info.role).toBe("assistant") + if (assistant?.info.role === "assistant") { + expect(assistant.info.finish).toBe("stop") + expect(assistant.info.time.completed).toBeDefined() + expect(assistant.info.error).toBeUndefined() + } + expect(assistant?.parts.map((part) => part.type)).toEqual(["step-start", "step-finish"]) + expect(result.recoveryStatus).toBe(200) + expect(result.candidates).toMatchObject([{ kind: "assistant", assistantMessageID: assistant?.info.id }]) + expect(result.resumeStatus).toBe(202) + expect(result.providerCalls).toBe(2) + expect(result.afterResume.some((message) => message.info.id === assistant?.info.id)).toBe(false) + const users = result.afterResume.filter((message) => message.info.role === "user") + expect(users.map((message) => message.info.id)).toEqual(result.atBarrier.filter((message) => message.info.role === "user").map((message) => message.info.id)) + const recovered = result.afterResume.find((message) => message.info.role === "assistant") + expect(recovered?.info).toMatchObject({ role: "assistant", parentID: users[0].info.id, finish: "stop", time: { completed: expect.any(Number) } }) + expect(recovered?.parts.some((part) => part.type === "text" && part.text === "recovered answer")).toBe(true) + } finally { + advance.mockRestore() + await Instance.disposeAll() + await stub.stop() + } + }, 30_000) + test("empty stop step is nudged, second call produces a non-empty final assistant", async () => { await using tmp = await tmpdir({ git: true }) const stub = startScriptedLLMServer([{ lines: emptyStopResponse() }, { lines: textStopResponse("final answer") }]) @@ -216,6 +287,7 @@ describe("invalid-output continuation — integration", () => { } }) + // [TP-SR-R21-07] test("GPT reasoning-only stop step is terminal and is not retried", async () => { await using tmp = await tmpdir({ git: true }) const stub = startScriptedLLMServer([ @@ -242,6 +314,14 @@ describe("invalid-output continuation — integration", () => { if (result.info.role === "assistant") expect(result.info.error).toBeUndefined() expect(result.parts.some((p) => p.type === "reasoning" && p.text.includes("let me think"))).toBe(true) expect(result.parts.some((p) => p.type === "text")).toBe(false) + expect(yield* prompt.recovery({ sessionID: session.id, agentID: "main" })).toEqual([]) + const resumed = yield* Effect.promise(() => Promise.resolve(Server.Default().app.request( + `/session/${session.id}/resume?directory=${encodeURIComponent(tmp.path)}`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, + ))) + expect(resumed.status).toBe(404) + expect(stub.captures.length).toBe(1) + expect((yield* sessions.messages({ sessionID: session.id, agentID: "main" })).find((message) => message.info.id === result.info.id)).toEqual(result) }), ), }) diff --git a/packages/cli/test/session/tool-gate-cancel.test.ts b/packages/cli/test/session/tool-gate-cancel.test.ts index d8fdd95dfe..9dfaaceb8d 100644 --- a/packages/cli/test/session/tool-gate-cancel.test.ts +++ b/packages/cli/test/session/tool-gate-cancel.test.ts @@ -23,7 +23,7 @@ const it = testEffect( ) it.live( - "a cancelled write queued behind a question in the same step never executes", + "a cancelled write queued behind a question in the same step never executes [TP-SR-R21-25]", () => Effect.gen(function* () { const server = startScriptedLLMServer([ @@ -53,9 +53,16 @@ it.live( const questions = yield* Question.Service // Observe actual release without replacing the gate implementation. const leaving = spyOn(ToolGate.prototype, "leave") + const entering = spyOn(ToolGate.prototype, "run") yield* Effect.addFinalizer(() => Effect.sync(() => leaving.mockRestore())) + yield* Effect.addFinalizer(() => Effect.sync(() => entering.mockRestore())) const asked = yield* Deferred.make() const queued = yield* Deferred.make() + const rejected: Question.Request["id"][] = [] + const unsubscribeRejected = yield* bus.subscribeCallback(Question.Event.Rejected, (event) => { + if (event.properties.sessionID === session.id) rejected.push(event.properties.requestID) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeRejected)) const unsubscribeAsked = yield* bus.subscribeCallback(Question.Event.Asked, (event) => { if (event.properties.sessionID === session.id) Deferred.doneUnsafe(asked, Effect.void) }) @@ -96,9 +103,14 @@ it.live( yield* prompt.cancel(session.id) const result = yield* Fiber.join(running) expect(result.info.role === "assistant" && result.info.error?.name).toBe("MessageAbortedError") - // Base question cancellation is unchanged. End its outstanding wait - // explicitly, then let the old batch drain before checking for writes. - yield* Effect.forEach(yield* questions.list(), (question) => questions.reject(question.id), { discard: true }) + expect(entering.mock.calls.find(([tool, callID]) => tool === "question" && callID === "ask-user")?.[3]?.signal?.aborted).toBe(true) + const pendingAfterStop = yield* questions.list() + const toolsAfterStop = (yield* sessions.messages({ sessionID: session.id })) + .flatMap((message) => message.parts) + .filter((part) => part.type === "tool") + expect(toolsAfterStop).toHaveLength(2) + expect(toolsAfterStop.every((part) => part.state.status === "error")).toBe(true) + expect({ pending: pendingAfterStop, rejected }).toEqual({ pending: [], rejected: [expect.any(String)] }) yield* Effect.promise(async () => { while (true) { const index = leaving.mock.calls.findIndex(([token]) => token.startsWith("ask-user#")) diff --git a/packages/cli/test/tool/plan.test.ts b/packages/cli/test/tool/plan.test.ts index 5546c0b56e..435e8017ea 100644 --- a/packages/cli/test/tool/plan.test.ts +++ b/packages/cli/test/tool/plan.test.ts @@ -48,6 +48,29 @@ const pending = Effect.fn("PlanToolTest.pending")(function* (question: Question. }) describe("tool.plan", () => { + it.live("plan_exit Stop withdraws its question without switching agents [TP-SR-R21-25]", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const question = yield* Question.Service + const info = yield* sessions.create({ title: "Test" }) + const tool = yield* (yield* PlanExitTool).init() + const controller = new AbortController() + const fiber = yield* tool + .execute({}, { ...ctx(info.id, "plan"), abort: controller.signal }) + .pipe(Effect.forkScoped) + const item = yield* pending(question) + expect(item.questions[0].key).toBe("plan_exit") + controller.abort() + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + expect(JSON.stringify(exit)).toContain("QuestionRejectedError") + expect(yield* question.list()).toEqual([]) + expect(yield* sessions.messages({ sessionID: info.id })).toEqual([]) + }).pipe(Effect.timeout("5 seconds")), + ), + ) + it.live("plan_exit answering No resolves with continue-planning guidance", () => provideTmpdirInstance(() => Effect.gen(function* () { From 85bd8fa96732a5582d033b275da978e7406d052e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Fri, 9 Oct 2026 15:34:21 +0800 Subject: [PATCH 02/15] fix(session): admit the exact reviewed resume target before history changes --- .../cli/src/server/routes/instance/session.ts | 37 +--- packages/cli/src/session/prompt.ts | 163 +++++++-------- packages/cli/src/session/resume-test-hooks.ts | 4 +- packages/cli/src/session/run-state.ts | 10 +- .../actor/subagent-resume-negatives.test.ts | 6 +- .../cli/test/server/session-recovery.test.ts | 197 ++++++++++++++++++ .../cli/test/session/prompt-effect.test.ts | 1 + packages/sdk/src/v2/gen/sdk.gen.ts | 6 +- packages/sdk/src/v2/gen/types.gen.ts | 1 + 9 files changed, 303 insertions(+), 122 deletions(-) diff --git a/packages/cli/src/server/routes/instance/session.ts b/packages/cli/src/server/routes/instance/session.ts index 3d43c69836..acefe1e8ec 100644 --- a/packages/cli/src/server/routes/instance/session.ts +++ b/packages/cli/src/server/routes/instance/session.ts @@ -1137,8 +1137,8 @@ export const SessionRoutes = lazy(() => .post( "/:sessionID/resume", describeRoute({ - summary: "Resume from a trailing user", - description: "Start the next turn from a trailing user message without creating another user message.", + summary: "Resume an exact recovery target", + description: "Admit a user or assistant recovery target without creating another user message. Omit the target to select the latest candidate.", operationId: "session.resumeUser", responses: { 202: { description: "Resume accepted" }, @@ -1158,9 +1158,11 @@ export const SessionRoutes = lazy(() => modelID: z.string().optional(), })), // Body is optional: empty/{} = resume latest recovery candidate. - // userMessageID still accepted for explicit trailing-user targeting (TUI / cascade). validator("json", z.object({ userMessageID: MessageID.zod.optional(), + assistantMessageID: MessageID.zod.optional(), + }).refine((body) => !(body.userMessageID && body.assistantMessageID), { + message: "userMessageID and assistantMessageID are mutually exclusive", }).optional()), async (c) => { const params = c.req.valid("param") @@ -1174,29 +1176,6 @@ export const SessionRoutes = lazy(() => c, SessionRunState.Service.use((svc) => svc.assertNotBusy(params.sessionID, query.agentID)), ) - // Explicit userMessageID → validate it is still the trailing user. - // Omitted → the engine resolves the latest recovery candidate (404 if none). - if (body?.userMessageID) { - await runRequest( - "SessionRoutes.resumeUser.validate", - c, - SessionPrompt.Service.use((svc) => - svc.recovery({ sessionID: params.sessionID, agentID: query.agentID }).pipe( - Effect.flatMap((candidates) => - candidates.some( - (candidate) => candidate.kind === "parent-user" && candidate.userMessageID === body.userMessageID, - ) - ? Effect.void - : Effect.fail( - new NotFoundError({ - message: "No resumable trailing user found for message " + body.userMessageID, - }), - ), - ), - ), - ), - ) - } // [TP-SR-R21-10] 202 = admission complete (plan + exclusive start), not fire-and-forget. // Main agent also cascades subagent recovery (same as /turn/:id/resume). const admitted = await runRequest( @@ -1206,7 +1185,8 @@ export const SessionRoutes = lazy(() => (query.agentID === undefined || query.agentID === "main") ? svc.resumeMainCascading({ sessionID: params.sessionID, - ...(body?.userMessageID ? { userMessageID: body.userMessageID } : {}), + ...body, + waitForAdmission: true, agentID: query.agentID, task_id: query.task_id, titleLocale: query.titleLocale, @@ -1214,7 +1194,8 @@ export const SessionRoutes = lazy(() => }) : svc.resumeBackground({ sessionID: params.sessionID, - ...(body?.userMessageID ? { userMessageID: body.userMessageID } : {}), + ...body, + waitForAdmission: true, agentID: query.agentID, task_id: query.task_id, titleLocale: query.titleLocale, diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 80ba23659f..f15cfa571f 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -51,6 +51,7 @@ import { Global } from "@/global" import { NotFoundError, Database, eq } from "@/storage" import { SessionTable } from "./session.sql" import { Bus } from "../bus" +import { Question } from "../question" import { ProviderTransform } from "../provider" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" @@ -606,6 +607,8 @@ export interface ResumeTurnInput { titleLocale?: string /** Optional model override: use the newly selected model for the recovery step. */ model?: { providerID: string; modelID: string } + /** Session-level HTTP resume waits for exact-target admission, not the full turn. */ + waitForAdmission?: boolean /** Cascade epoch captured at cascade start; cancel bumps it to invalidate pending resumes. */ cascadeEpoch?: number /** Fired after exclusive admission (acquire + epoch + candidate re-check) succeeded. */ @@ -630,6 +633,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const commands = yield* Command.Service const permission = yield* Permission.Service + const question = yield* Question.Service const fsys = yield* AppFileSystem.Service const mcp = yield* MCP.Service const lsp = yield* LSP.Service @@ -6244,9 +6248,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the */ type ResumePlan = | { action: "tool-resume"; assistantMessageID: MessageID; parentMessageID: MessageID } - | { action: "user-resume"; parentMessageID: MessageID; strictTail?: boolean } + | { action: "user-resume"; parentMessageID: MessageID; assistantMessageID?: MessageID; strictTail?: boolean } | { action: "reject"; error: InstanceType | Session.BusyError } + const resumeInputPending = Effect.fn("SessionPrompt.resumeInputPending")(function* (sessionID: SessionID) { + return (yield* permission.list()).some((item) => item.sessionID === sessionID) || + (yield* question.list()).some((item) => item.sessionID === sessionID) + }) + const planResume = Effect.fn("SessionPrompt.planResume")(function* (input: { sessionID: SessionID agentID: string @@ -6265,7 +6274,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // by an already-accepted main Resume (cascade after main 202). const statusBusy = input.allowSessionBusy !== true && (yield* status.get(input.sessionID)).type !== "idle" - if (runnerBusy || statusBusy) { + if (runnerBusy || statusBusy || (yield* resumeInputPending(input.sessionID))) { const busy: ResumePlan = { action: "reject", error: new Session.BusyError(input.sessionID), @@ -6321,7 +6330,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return tool } - const user: ResumePlan = { action: "user-resume", parentMessageID } + const user: ResumePlan = { action: "user-resume", parentMessageID, assistantMessageID: target.info.id } return user }) @@ -6334,6 +6343,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model?: ResumeTurnInput["model"] plan: Exclude mode: "ensure" | "start" + waitForAdmission?: boolean }) { // [C002] `*` is a message-read selector (every slice), not an execution owner. // Resume must lock the concrete slice Runner that will own the new assistant. @@ -6350,56 +6360,56 @@ NOTE: At any point in time through this workflow you should feel free to ask the // [R003] Trailing-user plan requires strict tail at first step. const strictTail = plan.action === "user-resume" && plan.strictTail === true // [C001] start() forks work; HTTP must wait for admission handshake, not the full turn. - const admission = yield* Deferred.make>() + const admission = yield* Deferred.make | Session.BusyError>() + + const admit = Effect.gen(function* () { + yield* Effect.yieldNow + const beforeAdmission = ResumeTestHooks.beforeAdmissionRecheck + if (beforeAdmission) yield* beforeAdmission() + if (yield* resumeInputPending(input.sessionID)) { + const err = new Session.BusyError(input.sessionID) + yield* Deferred.fail(admission, err) + throw err + } + const tailMsgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID }) + const parentIdx = tailMsgs.findIndex((m) => m.info.role === "user" && m.info.id === plan.parentMessageID) + const tail = tailMsgs.at(-1) + const after = tailMsgs.slice(parentIdx + 1) + const reason = parentIdx < 0 + ? "missing parent" + : after.some((m) => m.info.role === "user") + ? "stale at runner admission" + : strictTail && after.some((m) => m.info.role === "assistant") + ? "assistant after parent at admission" + : plan.assistantMessageID && ( + tail?.info.role !== "assistant" || + tail.info.id !== plan.assistantMessageID || + tail.info.parentID !== plan.parentMessageID || + !isRecoveryWorthyAssistant(tail.info, tail.parts) || + isEmptyAssistantResidue(tail.info, tail.parts) !== (plan.action === "user-resume") + ) + ? "assistant changed at runner admission" + : undefined + if (reason) { + const err = new NotFoundError({ message: `No resumable target for message ${plan.assistantMessageID ?? plan.parentMessageID} (${reason})` }) + yield* Deferred.fail(admission, err) + throw err + } + yield* Deferred.succeed(admission, void 0) + }).pipe( + Effect.ensuring( + Deferred.done(admission, Exit.fail(new NotFoundError({ message: "Resume admission did not complete" }))).pipe( + Effect.asVoid, + Effect.ignore, + ), + ), + ) - const work = - plan.action === "user-resume" - ? // [R003] Admission re-check runs OUTSIDE the ensuring(final-cleanup) scope so a - // stale reject has zero side effects (finalizer must not delete later messages). - Effect.gen(function* () { - yield* Effect.yieldNow - // [C001/C004] Test seam: stop after occupy, before admission re-check. - const beforeAdmission = ResumeTestHooks.beforeAdmissionRecheck - if (beforeAdmission) yield* beforeAdmission() - const tailMsgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID }) - const parentIdx = tailMsgs.findIndex( - (m) => m.info.role === "user" && m.info.id === plan.parentMessageID, - ) - if (parentIdx < 0) { - const err = new NotFoundError({ - message: - "No resumable trailing user found for message " + plan.parentMessageID + " (missing parent)", - }) - yield* Deferred.fail(admission, err) - throw err - } - const after = tailMsgs.slice(parentIdx + 1) - const laterUser = after.some((m) => m.info.role === "user") - if (laterUser) { - const err = new NotFoundError({ - message: - "No resumable trailing user found for message " + - plan.parentMessageID + - " (stale at runner admission)", - }) - yield* Deferred.fail(admission, err) - throw err - } - if (strictTail && after.some((m) => m.info.role === "assistant")) { - const err = new NotFoundError({ - message: - "No resumable trailing user found for message " + - plan.parentMessageID + - " (assistant after parent at admission)", - }) - yield* Deferred.fail(admission, err) - throw err - } - // Admission granted — tell the waiting HTTP/sync caller before runLoop. - yield* Deferred.succeed(admission, void 0) - // [C003] Test seam: after handshake, before any cleanup read. - const afterAdmission = ResumeTestHooks.afterAdmissionBeforeCleanup - if (afterAdmission) yield* afterAdmission() + const work = Effect.gen(function* () { + const afterAdmission = ResumeTestHooks.afterAdmissionBeforeCleanup + if (afterAdmission) yield* afterAdmission() + return yield* (plan.action === "user-resume" + ? Effect.gen(function* () { // [C003] strict trailing-user must NOT pre-clean: deleting a later // empty assistant would manufacture a legal tail after admission. // Empty-shell user-resume still clears residue under parent so @@ -6446,17 +6456,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ), ) return result - }).pipe( - // If work dies before handshake (interrupt/defect), release the waiter. - Effect.ensuring( - Deferred.done( - admission, - Exit.fail( - new NotFoundError({ message: "Resume admission did not complete" }), - ), - ).pipe(Effect.asVoid, Effect.ignore), - ), - ) + }) : Effect.gen(function* () { yield* cleanupEmptyResidueAssistants({ sessionID: input.sessionID, @@ -6470,7 +6470,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the assistantMessageID: plan.assistantMessageID, agentID: input.agentID, }) - yield* Deferred.succeed(admission, void 0) return yield* runLoop( input.sessionID, input.agentID, @@ -6482,18 +6481,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the // tool-resume continues an assistant turn — not a user-source turn. undefined, "hook", + plan.parentMessageID, ) }).pipe( - // Complete the HTTP/sync waiter BEFORE other finalizers — an interrupt - // during cleanup/abandon must not leave Deferred.await(admission) hanging. - Effect.ensuring( - Deferred.done( - admission, - Exit.fail( - new NotFoundError({ message: "Resume admission did not complete" }), - ), - ).pipe(Effect.asVoid, Effect.ignore), - ), Effect.ensuring( abandonRecoveredAssistant({ sessionID: input.sessionID, @@ -6509,18 +6499,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the ), ), ), - ) + )) + }) // [R003] Exclusive admission: never join an unrelated run (ensureRunning would). // ensure mode waits for work result (sync resume()); start mode forks (HTTP 202 after handshake). if (input.mode === "ensure") { - return yield* state.ensureExclusive(input.sessionID, input.agentID, resumeInterrupt, work) + return yield* state.ensureExclusive(input.sessionID, input.agentID, resumeInterrupt, admit.pipe(Effect.andThen(work))) + } + if (!input.waitForAdmission && !strictTail) { + yield* state.start(input.sessionID, input.agentID, resumeInterrupt, work, admit) + return undefined } - // [C001] Trailing-user resumeUser: wait for admission handshake, and on - // timeout interrupt THIS run only (never a replacement). Assistant resume / - // cascade stay fire-and-forget after start() (D16f-HTTP). - if (plan.action === "user-resume" && plan.strictTail === true) { - const owned = yield* state.startOwned(input.sessionID, input.agentID, resumeInterrupt, work) + const owned = yield* state.startOwned(input.sessionID, input.agentID, resumeInterrupt, work, admit) + if (input.waitForAdmission || strictTail) { const handshake = yield* Deferred.await(admission).pipe( Effect.timeout("5 seconds"), Effect.exit, @@ -6544,7 +6536,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } return } - yield* state.start(input.sessionID, input.agentID, resumeInterrupt, work) }) const resume = Effect.fn("SessionPrompt.resume")(function* (input: ResumeTurnInput) { @@ -6588,7 +6579,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // [R004] Test seam: expose resolved plan so tests can assert the actual target. ResumeTestHooks.onPlanResolved?.({ action: plan.action, - ...(plan.action === "tool-resume" ? { assistantMessageID: plan.assistantMessageID } : {}), + ...(plan.assistantMessageID ? { assistantMessageID: plan.assistantMessageID } : {}), parentMessageID: plan.parentMessageID, }) // [C004] Test seam: pause after ALL busy preflights (incl. planResume), before exclusive occupy. @@ -6654,9 +6645,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the // [R004] Test seam: expose resolved plan so tests can assert the actual target. ResumeTestHooks.onPlanResolved?.({ action: plan.action, - ...(plan.action === "tool-resume" ? { assistantMessageID: plan.assistantMessageID } : {}), + ...(plan.assistantMessageID ? { assistantMessageID: plan.assistantMessageID } : {}), parentMessageID: plan.parentMessageID, }) + const beforeOccupy = ResumeTestHooks.beforeExclusiveOccupy + if (beforeOccupy) yield* beforeOccupy() yield* launchResume({ sessionID: input.sessionID, agentID, @@ -6665,6 +6658,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: input.model, plan, mode: "start", + waitForAdmission: input.waitForAdmission, }) return }) @@ -6963,6 +6957,7 @@ export const appLayer = Layer.suspend(() => Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Command.appLayer), Layer.provide(Permission.defaultLayer), + Layer.provide(Question.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(ToolRegistry.defaultLayer), Layer.provide(Truncate.defaultLayer), diff --git a/packages/cli/src/session/resume-test-hooks.ts b/packages/cli/src/session/resume-test-hooks.ts index 5404f30bc0..42cc9dfa42 100644 --- a/packages/cli/src/session/resume-test-hooks.ts +++ b/packages/cli/src/session/resume-test-hooks.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" /** - * Deterministic race seams for trailing-user resume tests. + * Deterministic race seams for exact-target resume tests. * Production leaves these undefined (no-ops). Tests install barriers to stop * work after runner occupy and before admission re-check / step-0 parent lock. */ export const ResumeTestHooks = { - /** After runner occupy (start/ensureExclusive), before user-resume admission re-check. */ + /** After runner occupy (start/ensureExclusive), before exact-target admission re-check. */ beforeAdmissionRecheck: undefined as undefined | (() => Effect.Effect), /** [C003] After admission re-check / handshake, before any residue cleanup read. */ afterAdmissionBeforeCleanup: undefined as undefined | (() => Effect.Effect), diff --git a/packages/cli/src/session/run-state.ts b/packages/cli/src/session/run-state.ts index 168265cebf..51da9bc9f5 100644 --- a/packages/cli/src/session/run-state.ts +++ b/packages/cli/src/session/run-state.ts @@ -10,13 +10,14 @@ import { orphanToolIdleSweepRef, assistantMessageIdsSnapshotRef } from "./orphan export interface Interface { readonly assertNotBusy: (sessionID: SessionID, agentID?: string) => Effect.Effect - readonly start: (sessionID: SessionID, agentID: string, onInterrupt: Effect.Effect, work: Effect.Effect) => Effect.Effect + readonly start: (sessionID: SessionID, agentID: string, onInterrupt: Effect.Effect, work: Effect.Effect, admit?: Effect.Effect) => Effect.Effect /** [C001] Start and return cancel bound to this run id only. */ readonly startOwned: ( sessionID: SessionID, agentID: string, onInterrupt: Effect.Effect, work: Effect.Effect, + admit?: Effect.Effect, ) => Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect }, Session.BusyError> readonly cancel: (sessionID: SessionID) => Effect.Effect readonly cancelActor: (sessionID: SessionID, agentID: string) => Effect.Effect @@ -144,9 +145,10 @@ export const layer = Layer.effect( agentID: string, onInterrupt: Effect.Effect, work: Effect.Effect, + admit?: Effect.Effect, ) { const active = yield* runner(sessionID, agentID, onInterrupt) - yield* active.start(withOrphanSweep(sessionID, agentID, work)) + yield* active.start((admit ?? Effect.void).pipe(Effect.andThen(withOrphanSweep(sessionID, agentID, work)))) return }) @@ -155,9 +157,11 @@ export const layer = Layer.effect( agentID: string, onInterrupt: Effect.Effect, work: Effect.Effect, + admit?: Effect.Effect, ) { const active = yield* runner(sessionID, agentID, onInterrupt) - return yield* active.startOwned(withOrphanSweep(sessionID, agentID, work)) + // An unadmitted run owns no history to settle, even on cancellation. + return yield* active.startOwned((admit ?? Effect.void).pipe(Effect.andThen(withOrphanSweep(sessionID, agentID, work)))) }) // Process-group kill: session abort cancels EVERY runner under this session diff --git a/packages/cli/test/actor/subagent-resume-negatives.test.ts b/packages/cli/test/actor/subagent-resume-negatives.test.ts index 542804fe3b..5f274ef9ff 100644 --- a/packages/cli/test/actor/subagent-resume-negatives.test.ts +++ b/packages/cli/test/actor/subagent-resume-negatives.test.ts @@ -454,10 +454,10 @@ describe("subagent resume recovery negatives", () => { (m) => m.info.role === "assistant", ).length const patched = Object.assign( - (sid: SessionID, aid: string, oi: never, work: never) => - origStart(sid, aid, oi, work).pipe( + (...args: Parameters) => + origStart(...args).pipe( Effect.tap(() => - aid === "main" && sid === session.id + args[1] === "main" && args[0] === session.id ? Effect.promise(async () => { await Effect.runPromise(Deferred.succeed(accepted, undefined)) await Effect.runPromise(Deferred.await(release)) diff --git a/packages/cli/test/server/session-recovery.test.ts b/packages/cli/test/server/session-recovery.test.ts index 829a38efdc..792a104c34 100644 --- a/packages/cli/test/server/session-recovery.test.ts +++ b/packages/cli/test/server/session-recovery.test.ts @@ -6,6 +6,11 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { SessionRunState } from "../../src/session/run-state" +import { ActorRegistry } from "../../src/actor/registry" +import { Question } from "../../src/question" +import { Permission } from "../../src/permission" import { ResumeTestHooks } from "../../src/session/resume-test-hooks" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -718,4 +723,196 @@ test("SDK recovery response is union and resumeUser posts body userMessageID", a expect(resumeCall!.method).toBe("POST") expect(resumeCall!.body).toEqual({ userMessageID: "msg_u2" }) expect(resumeCall!.url).toContain("titleLocale=fr-FR") + // [TP-SR-R21-26] + await client.session.resumeUser({ sessionID: "ses_test", assistantMessageID: "msg_a" }) + expect(calls.at(-1)?.body).toEqual({ assistantMessageID: "msg_a" }) }) + +// [TP-SR-R21-26] +for (const shape of ["parent-user", "empty-assistant", "tool-assistant"] as const) { + for (const window of ["before-http", "before-occupy", "after-occupy"] as const) { + for (const change of ["user", "assistant", "busy-idle"] as const) { + test(`exact resume rejects ${shape} ${window} ${change} without history or cascade effects [TP-SR-R21-26]`, () => + exactResumeCase(shape, window, change), 15000) + } + } + test(`exact resume admits unchanged ${shape} only after final check [TP-SR-R21-26]`, () => + exactResumeCase(shape, "after-occupy", "unchanged"), 15000) + test(`exact resume cancellation preserves unadmitted ${shape} [TP-SR-R21-26]`, () => + exactResumeCase(shape, "after-occupy", "cancel"), 15000) + test(`exact resume refuses busy status for ${shape} [TP-SR-R21-26]`, () => + exactResumeCase(shape, "before-http", "busy"), 15000) + test(`exact resume refuses Runner occupied after planning ${shape} [TP-SR-R21-26]`, () => + exactResumeCase(shape, "before-occupy", "occupied"), 15000) + test(`exact resume rejects both target fields for ${shape} [TP-SR-R21-26]`, () => + exactResumeCase(shape, "before-http", "both"), 15000) + for (const window of ["before-http", "after-occupy"] as const) { + for (const change of ["question", "permission"] as const) { + test(`exact resume preserves pending ${change} for ${shape} ${window} [TP-SR-R21-26]`, () => + exactResumeCase(shape, window, change), 15000) + } + } + if (shape !== "parent-user") { + for (const change of ["qualified", "parent"] as const) { + test(`exact resume rejects changed ${change} for ${shape} at admission [TP-SR-R21-26]`, () => + exactResumeCase(shape, "after-occupy", change), 15000) + } + } +} + +async function exactResumeCase( + shape: "parent-user" | "empty-assistant" | "tool-assistant", + window: "before-http" | "before-occupy" | "after-occupy", + change: "user" | "assistant" | "busy-idle" | "unchanged" | "cancel" | "busy" | "both" | "question" | "permission" | "qualified" | "parent" | "occupied", +) { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: textStopResponse("EXACT_A_RESUMED") }]) + const reached = Promise.withResolvers() + const released = Promise.withResolvers() + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) + await Instance.provide({ + directory: tmp.path, + fn: () => AppRuntime.runPromise(Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const status = yield* SessionStatus.Service + const runs = yield* SessionRunState.Service + const actors = yield* ActorRegistry.Service + const questions = yield* Question.Service + const permissions = yield* Permission.Service + const session = yield* sessions.create({ title: "exact resume" }) + const model = { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") } + const created = Date.now() - 100 + const user = (text: string, time: number, agentID = "main") => Effect.gen(function* () { + const info = yield* sessions.updateMessage({ + id: MessageID.ascending(), role: "user", sessionID: session.id, agent: "build", agentID, + model, time: { created: time }, + }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: info.id, type: "text", text }) + return info + }) + const assistant = (parentID: MessageID, time: number, agentID = "main") => sessions.updateMessage({ + id: MessageID.ascending(), role: "assistant", parentID, sessionID: session.id, agentID, + mode: "build", agent: "build", path: { cwd: tmp.path, root: tmp.path }, cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...model, time: { created: time }, finish: "tool-calls", + }) + const oldUser = yield* user("older task", created) + const orphan = yield* assistant(oldUser.id, created + 1) + yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: orphan.id, + type: "tool", tool: "bash", callID: "untouched-orphan", + state: { status: "running", input: { command: "true" }, time: { start: created } }, + }) + const a = yield* user("APPROVED_A", created + 2) + const target = shape === "parent-user" ? undefined : yield* assistant(a.id, created + 3) + if (target && shape === "tool-assistant") yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: target.id, + type: "tool", tool: "bash", callID: "completed-once", + state: { status: "completed", input: { command: "true" }, output: "COMPLETED_OBSERVATION_A", + title: "completed", metadata: {}, time: { start: created, end: created + 1 } }, + }) + if (change !== "unchanged") { + yield* actors.register({ + sessionID: session.id, actorID: "child-1", mode: "subagent", agent: "build", + description: "untouched child", contextMode: "none", background: true, lifecycle: "ephemeral", + }) + yield* actors.updateStatus(session.id, "child-1", { status: "idle", lastOutcome: "failure", lastError: "interrupted" }) + const child = yield* user("CHILD_MUST_NOT_RESUME", created + 4, "child-1") + yield* assistant(child.id, created + 5, "child-1") + } + const beforeChild = yield* actors.get(session.id, "child-1") + const mutate = Effect.gen(function* () { + if (change === "occupied") yield* runs.startOwned(session.id, "main", Effect.interrupt, Effect.never) + if (change === "busy-idle" && window !== "after-occupy") { + yield* runs.ensureExclusive(session.id, "main", Effect.interrupt, + user("UNAPPROVED_B", created + 10).pipe(Effect.as({ info: orphan, parts: [] }))) + expect((yield* status.get(session.id)).type).toBe("idle") + return + } + if (change === "busy" || change === "busy-idle") yield* status.set(session.id, { type: "busy" }) + if (change === "user" || change === "busy-idle") yield* user("UNAPPROVED_B", created + 10) + if (change === "assistant") { + const b = yield* assistant(a.id, created + 10) + yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: b.id, + type: "tool", tool: "bash", callID: "unapproved-b-tool", + state: { status: "running", input: { command: "true" }, time: { start: created } }, + }) + } + if (change === "busy-idle") yield* status.set(session.id, { type: "idle" }) + if (target && change === "parent") yield* sessions.updateMessage({ ...target, parentID: oldUser.id }) + if (target && change === "qualified") yield* sessions.updateMessage({ + ...target, finish: "stop", structured: { answer: "delivered" }, time: { ...target.time, completed: Date.now() }, + }) + if (change === "question") { + yield* questions.ask({ sessionID: session.id, questions: [{ question: "Pending?", header: "Pending", options: [] }] }).pipe(Effect.forkChild) + while ((yield* questions.list()).length === 0) yield* Effect.sleep("1 millis") + } + if (change === "permission") { + yield* permissions.ask({ sessionID: session.id, permission: "bash", patterns: ["*"], always: [], metadata: {}, ruleset: [] }).pipe(Effect.forkChild) + while ((yield* permissions.list()).length === 0) yield* Effect.sleep("1 millis") + } + }) + if (window === "before-http") yield* mutate + if (window !== "before-http") { + const hook = () => Effect.sync(() => reached.resolve()).pipe(Effect.andThen(Effect.promise(() => released.promise))) + if (window === "before-occupy") ResumeTestHooks.beforeExclusiveOccupy = hook + if (window === "after-occupy") ResumeTestHooks.beforeAdmissionRecheck = hook + } + const payload = change === "both" + ? { userMessageID: a.id, assistantMessageID: target?.id ?? MessageID.ascending() } + : target ? { assistantMessageID: target.id } : { userMessageID: a.id } + const app = Server.Default().app + const request = Promise.resolve(app.request(`/session/${session.id}/resume?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + })) + if (window !== "before-http") { + const first = yield* Effect.promise(() => Promise.race([ + reached.promise.then(() => "barrier" as const), request.then(() => "response" as const), + ])) + expect(first).toBe("barrier") + yield* mutate + } + const before = yield* sessions.messages({ sessionID: session.id, agentID: "*" }) + if (change === "cancel") yield* prompt.cancel(session.id) + released.resolve() + const response = yield* Effect.promise(() => request) + expect(response.status).toBe(change === "unchanged" ? 202 : change === "busy" || change === "occupied" || change === "question" || change === "permission" ? 409 : change === "both" ? 400 : 404) + if (change === "unchanged") { + for (let attempt = 0; attempt < 100; attempt++) { + const messages = yield* sessions.messages({ sessionID: session.id }) + if (messages.some((message) => message.parts.some((part) => part.type === "text" && part.text === "EXACT_A_RESUMED"))) break + yield* Effect.sleep("25 millis") + } + const after = yield* sessions.messages({ sessionID: session.id }) + const answer = after.find((message) => message.parts.some((part) => part.type === "text" && part.text === "EXACT_A_RESUMED")) + expect(answer?.info).toMatchObject({ parentID: a.id }) + expect(after.filter((message) => message.info.role === "user").map((message) => message.info.id)).toEqual([oldUser.id, a.id]) + expect(stub.captures).toHaveLength(1) + if (shape === "tool-assistant") { + expect(JSON.stringify(stub.captures[0].messages)).toContain("COMPLETED_OBSERVATION_A") + expect(after.flatMap((message) => message.parts).find((part) => part.type === "tool" && part.callID === "completed-once")) + .toEqual(before.flatMap((message) => message.parts).find((part) => part.type === "tool" && part.callID === "completed-once")) + } + } else { + expect(yield* sessions.messages({ sessionID: session.id, agentID: "*" })).toEqual(before) + expect(yield* actors.get(session.id, "child-1")).toEqual(beforeChild) + expect(stub.captures).toHaveLength(0) + if (change === "question") expect(yield* questions.list()).toHaveLength(1) + if (change === "permission") expect(yield* permissions.list()).toHaveLength(1) + } + yield* prompt.cancel(session.id) + }).pipe(Effect.timeout("12 seconds"))), + }) + } finally { + released.resolve() + ResumeTestHooks.reset() + await stub.stop() + } +} diff --git a/packages/cli/test/session/prompt-effect.test.ts b/packages/cli/test/session/prompt-effect.test.ts index 2360acd80b..60eaea5cdc 100644 --- a/packages/cli/test/session/prompt-effect.test.ts +++ b/packages/cli/test/session/prompt-effect.test.ts @@ -5273,6 +5273,7 @@ describe("trailing-user resume integration", () => { .resume({ sessionID: chat.id, userMessageID: user2.id, agentID: "main", model: ref }) .pipe(Effect.exit, Effect.forkChild) yield* Effect.promise(() => beforeOccupy.reached) + ResumeTestHooks.beforeExclusiveOccupy = undefined // Second start-mode resume occupies the runner while first is still pre-occupy. const second = yield* prompt .resumeBackground({ sessionID: chat.id, userMessageID: user2.id, agentID: "main", model: ref }) diff --git a/packages/sdk/src/v2/gen/sdk.gen.ts b/packages/sdk/src/v2/gen/sdk.gen.ts index 614d6580ba..807b73fe29 100644 --- a/packages/sdk/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/src/v2/gen/sdk.gen.ts @@ -2703,9 +2703,9 @@ export class Session2 extends HeyApiClient { } /** - * Resume from a trailing user + * Resume an exact recovery target * - * Start the next turn from a trailing user message without creating another user message. + * Admit a user or assistant recovery target without creating another user message. Omit the target to select the latest candidate. */ public resumeUser( parameters: { @@ -2718,6 +2718,7 @@ export class Session2 extends HeyApiClient { modelProviderID?: string modelID?: string userMessageID?: string + assistantMessageID?: string }, options?: Options, ) { @@ -2735,6 +2736,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "modelProviderID" }, { in: "query", key: "modelID" }, { in: "body", key: "userMessageID" }, + { in: "body", key: "assistantMessageID" }, ], }, ], diff --git a/packages/sdk/src/v2/gen/types.gen.ts b/packages/sdk/src/v2/gen/types.gen.ts index 950a67eb62..0148a502af 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -5591,6 +5591,7 @@ export type SessionResumeResponses = { export type SessionResumeUserData = { body?: { userMessageID?: string + assistantMessageID?: string } path: { sessionID: string From 78309e22e4e703d2d74ebc235d07b6d7425747ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Sat, 10 Oct 2026 13:52:25 +0800 Subject: [PATCH 03/15] fix(session): persist safe resume boundaries and own execution through exit --- packages/cli/src/effect/runner.ts | 8 +- packages/cli/src/provider/host-transport.ts | 30 ++ .../cli/src/server/routes/instance/session.ts | 1 + packages/cli/src/session/llm.ts | 5 + packages/cli/src/session/message-v2.ts | 91 +++++ packages/cli/src/session/processor.ts | 1 + packages/cli/src/session/prompt.ts | 278 +++++---------- packages/cli/src/session/run-state.ts | 2 +- packages/cli/test/effect/runner.test.ts | 87 +++++ .../cli/test/server/session-recovery.test.ts | 335 ++++++++++++++++-- .../invalid-output-continuation.test.ts | 5 +- packages/cli/test/session/message-v2.test.ts | 125 +++++++ .../cli/test/session/prompt-effect.test.ts | 35 +- .../cli/test/session/tool-gate-cancel.test.ts | 138 +++++++- packages/sdk/src/v2/gen/sdk.gen.ts | 5 + packages/sdk/src/v2/gen/types.gen.ts | 5 + 16 files changed, 897 insertions(+), 254 deletions(-) diff --git a/packages/cli/src/effect/runner.ts b/packages/cli/src/effect/runner.ts index 8e880ad587..8cc404cc24 100644 --- a/packages/cli/src/effect/runner.ts +++ b/packages/cli/src/effect/runner.ts @@ -13,7 +13,7 @@ export interface Runner { */ readonly startOwned: ( work: Effect.Effect, - ) => Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect }, B> + ) => Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect; readonly awaitExit: Effect.Effect }, B> readonly startShell: (work: Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -98,6 +98,7 @@ export const make = ( Effect.gen(function* () { const id = next() const release = opts?.onRunStart ? yield* opts.onRunStart : () => {} + yield* busy const fiber = yield* work.pipe( Effect.onExit((exit) => finishRun(id, done, exit)), Effect.forkIn(scope), @@ -344,18 +345,19 @@ export const make = ( const startOwned = ( work: Effect.Effect, - ): Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect }, B> => + ): Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect; readonly awaitExit: Effect.Effect }, B> => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { if (st._tag !== "Idle") { - return [busyFailure<{ runId: number; interruptOwned: Effect.Effect }>(), st] as const + return [busyFailure<{ runId: number; interruptOwned: Effect.Effect; awaitExit: Effect.Effect }>(), st] as const } const done = yield* Deferred.make() const run = yield* startRun(work, done) const owned = { runId: run.id, interruptOwned: interruptOwned(run.id), + awaitExit: Fiber.await(run.fiber).pipe(Effect.asVoid), } return [Effect.succeed(owned), { _tag: "Running", run } as const] as const }), diff --git a/packages/cli/src/provider/host-transport.ts b/packages/cli/src/provider/host-transport.ts index 23caeac9de..d13dc399fb 100644 --- a/packages/cli/src/provider/host-transport.ts +++ b/packages/cli/src/provider/host-transport.ts @@ -1,8 +1,12 @@ +import { Context } from "effect" + /** Optional in-process embedding port. No application policy lives in the engine. */ export interface ModelCallScope { sessionID: string userMessageID: string assistantMessageID?: string + executionAttempt?: ExecutionAttempt + directory?: string providerID: string modelID: string sdk: string @@ -11,7 +15,24 @@ export interface ModelCallScope { format?: string } +export interface ExecutionAttempt { requestId: string; attemptId: string } +export interface ExecutionAdmission { + attempt: ExecutionAttempt + directory: string + sessionID: string + userMessageID: string + target: { userMessageID?: string; assistantMessageID?: string } +} + +export const ExecutionScope = Context.Reference | undefined>("~opencode/HostExecutionScope", { + defaultValue: () => undefined, +}) + +export interface ExecutionActivation { beforeWork: () => Promise } export interface HostTransport { + prepareExecution?(input: ExecutionAdmission): void | Promise + admitExecution?(input: ExecutionAdmission): ExecutionActivation | void + finishExecution?(attempt: ExecutionAttempt): void userMessage(input: { sessionID: string; userMessageID: string; parts: readonly unknown[] }): void modelCall(scope: ModelCallScope, call: () => Promise): Promise request?(input: unknown, init: unknown, forward: () => Promise): Promise @@ -20,6 +41,15 @@ export interface HostTransport { let host: HostTransport | undefined export function set(value: HostTransport | undefined) { host = value } export function userMessage(input: Parameters[0]) { host?.userMessage(input) } +export function prepareExecution(input: ExecutionAdmission) { + if (!host?.admitExecution) throw new Error("Execution host is unavailable") + return host.prepareExecution?.(input) +} +export function admitExecution(input: ExecutionAdmission) { + if (!host?.admitExecution) throw new Error("Execution host is unavailable") + return host.admitExecution(input) +} +export function finishExecution(attempt: ExecutionAttempt) { host?.finishExecution?.(attempt) } export function modelCall(scope: ModelCallScope, call: () => Promise): Promise { return host ? host.modelCall(scope, call) : call() } diff --git a/packages/cli/src/server/routes/instance/session.ts b/packages/cli/src/server/routes/instance/session.ts index acefe1e8ec..b2fbb4c01e 100644 --- a/packages/cli/src/server/routes/instance/session.ts +++ b/packages/cli/src/server/routes/instance/session.ts @@ -1161,6 +1161,7 @@ export const SessionRoutes = lazy(() => validator("json", z.object({ userMessageID: MessageID.zod.optional(), assistantMessageID: MessageID.zod.optional(), + attempt: z.object({ requestId: z.string().min(1), attemptId: z.string().min(1) }).optional(), }).refine((body) => !(body.userMessageID && body.assistantMessageID), { message: "userMessageID and assistantMessageID are mutually exclusive", }).optional()), diff --git a/packages/cli/src/session/llm.ts b/packages/cli/src/session/llm.ts index 9d6887fa27..91d9002367 100644 --- a/packages/cli/src/session/llm.ts +++ b/packages/cli/src/session/llm.ts @@ -679,6 +679,10 @@ const live: Layer.Layer< ) .pipe(Effect.ignore) + const execution = yield* HostModelTransport.ExecutionScope + const executionScope = !input.ephemeral && (!input.agentID || input.agentID === "main") + && execution?.sessionID === input.sessionID && execution.userMessageID === input.user.id + ? { executionAttempt: execution.attempt, directory: execution.directory } : {} const result = streamText({ onError(error) { l.debug("streamText error", { @@ -753,6 +757,7 @@ const live: Layer.Layer< { specificationVersion: "v3" as const, wrapStream: ({ doStream, params }) => HostModelTransport.modelCall({ + ...executionScope, sessionID: input.sessionID, userMessageID: input.user.id, assistantMessageID: input.assistantMessageID, providerID: input.model.providerID, modelID: input.model.id, sdk: input.model.api.npm, diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index e91e418892..96772aa150 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -442,6 +442,7 @@ export const ToolStateError = z .object({ status: z.literal("error"), input: z.record(z.string(), z.any()), + inputComplete: z.boolean().optional(), error: z.string(), metadata: z.record(z.string(), z.any()).optional(), time: z.object({ @@ -468,6 +469,7 @@ export function abortedToolState(state: ToolPart["state"], error = "Tool executi return { status: "error", input: state.input, + inputComplete: state.status === "error" ? state.inputComplete : state.status !== "pending", error, metadata: { ...("metadata" in state && state.metadata ? state.metadata : {}), interrupted: true }, time: { start: "time" in state ? state.time.start : end, end }, @@ -846,6 +848,90 @@ function providerMeta(metadata: Record | undefined) { return Object.keys(rest).length > 0 ? rest : undefined } +export function planResumeTrim(input: WithParts[], parentID: MessageID) { + const parent = input.findIndex((message) => message.info.id === parentID && message.info.role === "user") + if (parent < 0) throw new Error("Resume parent is missing") + const tail = input.slice(parent + 1) + if (tail.some((message) => message.info.role !== "assistant" || message.info.parentID !== parentID)) { + throw new Error("Resume trim crosses a parent boundary") + } + const lastToolMessage = tail.findLastIndex((message) => message.parts.some((part) => part.type === "tool")) + const removeMessages: MessageID[] = [] + const removeParts: Part[] = [] + const updateParts: ToolPart[] = [] + const updateMessages: Assistant[] = [] + const retained = tail.flatMap((message, index): WithParts[] => { + const info = message.info as Assistant + const lastTool = message.parts.findLastIndex((part) => part.type === "tool") + // SDK flushes a whole assistant block before emitting its tool results. + const nextBlock = index === lastToolMessage + ? message.parts.findIndex((part, partIndex) => partIndex > lastTool && part.type === "step-start") + : -1 + const cut = index < lastToolMessage ? message.parts.length + : index === lastToolMessage ? (nextBlock < 0 ? message.parts.length : nextBlock) + : 0 + const discarded = message.parts.slice(cut) + const hasContent = discarded.some((part) => + part.type === "text" ? !part.ignored && part.text.trim().length > 0 + : part.type === "reasoning" ? part.text.trim().length > 0 : part.type === "tool", + ) + if (cut < message.parts.length && ( + info.summary || info.structured !== undefined || + (!info.error && info.finish && info.finish !== "length" && info.finish !== "tool-calls" && + (info.finish === "stop" || info.time.completed !== undefined) && discarded.some((part) => + part.type === "text" ? !part.synthetic && !part.ignored && part.text.trim().length > 0 + : part.type === "reasoning" && part.text.trim().length > 0)) || + discarded.some((part) => !["text", "reasoning", "step-start", "step-finish", "retry"].includes(part.type)) + )) throw new Error("Resume trim would discard protected output") + if (cut === 0 && !hasContent) return [message] + if (cut === 0) { + removeMessages.push(info.id) + return [] + } + removeParts.push(...discarded) + const parts = message.parts.slice(0, cut).map((part) => { + if (part.type !== "tool") return part + if (part.state.status === "pending" || (part.state.status === "error" && ( + part.state.inputComplete === false || + (part.state.metadata?.interrupted === true && part.state.inputComplete !== true) + ))) throw new Error("Resume tool input has no execution boundary") + if (part.state.status !== "running") return part + const settled = { ...part, state: abortedToolState(part.state) } + updateParts.push(settled) + return settled + }) + if (info.error && !AbortedError.isInstance(info.error) && parts.some((part) => part.type === "tool")) { + const restored = { ...info, error: new AbortedError({ message: "Interrupted before resume" }).toObject() } + updateMessages.push(restored) + return [{ info: restored, parts }] + } + return [{ info, parts }] + }) + return { messages: [...input.slice(0, parent + 1), ...retained], removeMessages, removeParts, updateParts, updateMessages } +} + +export function assertResumeModelBoundary(messages: ModelMessage[]) { + const pending = new Map() + for (const message of messages) { + if (!Array.isArray(message.content)) continue + for (const part of message.content) { + if (part.type === "tool-call") { + if (pending.has(part.toolCallId)) throw new Error("Duplicate resume tool call") + pending.set(part.toolCallId, part.toolName) + } + if (part.type === "tool-result") { + if (pending.get(part.toolCallId) !== part.toolName) throw new Error("Unpaired resume tool result") + pending.delete(part.toolCallId) + } + } + } + if (pending.size > 0) throw new Error("Missing resume tool result") + if (messages.at(-1)?.role === "assistant") { + throw new Error("Resume requires a user or complete client tool-result boundary") + } + if (messages.length === 0) throw new Error("Resume has no model input") +} + export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, @@ -919,6 +1005,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( for (const msg of source) { if (msg.parts.length === 0) continue + if (msg.info.role === "assistant" && msg.parts.every((part) => + part.type === "step-start" || part.type === "step-finish" || part.type === "retry" || + (part.type === "text" && (part.ignored || part.text.trim().length === 0)) || + (part.type === "reasoning" && part.text.trim().length === 0), + )) continue if (projection && msg.info.id === projection.summary_message_id) { result.push({ diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 5939340d3e..141262d4a6 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -429,6 +429,7 @@ export const layer: Layer.Layer< state: { status: "error", input: match.part.state.input, + inputComplete: true, error: errorMessage(error), ...(Object.keys(metadata).length > 0 ? { metadata } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index f15cfa571f..16d3adfebe 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -598,6 +598,7 @@ export type RecoveryCandidate = export interface ResumeTurnInput { sessionID: SessionID + attempt?: HostModelTransport.ExecutionAttempt /** tool/user-resume of an incomplete assistant (existing). */ assistantMessageID?: MessageID /** trailing-user resume: start a new run from this user (no new user message). */ @@ -3527,57 +3528,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return true } - /** - * Delete empty-residue assistants under one parent user. - * `parentMessageID` is required: cleanup is parent-scoped, never session-wide. - * - Live empty shells (busy, no terminal marker) are skipped by default. - * - `preserveError`: keep empty shells that carry `error` (recovery candidates / failure site). - * - `force`: post-run ensuring sweep; still respects preserveError. - * Resolved structured output and summaries are retained even without useful parts. - * `error` is a keep-for-recovery signal, not useful content. - */ - const cleanupEmptyResidueAssistants = Effect.fn("SessionPrompt.cleanupEmptyResidueAssistants")(function* (input: { - sessionID: SessionID - agentID?: string - parentMessageID: MessageID - sessionBusy?: boolean - force?: boolean - /** Default true: keep error-marked empty shells for recovery; user-resume pre-clean passes false. */ - preserveError?: boolean - }) { - const busy = input.sessionBusy ?? (yield* status.get(input.sessionID)).type !== "idle" - const preserveError = input.preserveError ?? true - const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID ?? "main" }) - const removed: MessageID[] = [] - let skippedLive = 0 - let skippedError = 0 - for (const msg of msgs) { - if (msg.info.role !== "assistant") continue - if (msg.info.parentID !== input.parentMessageID) continue - if (!isEmptyAssistantResidue(msg.info, msg.parts)) continue - if (preserveError && msg.info.error) { - skippedError += 1 - continue - } - const liveOwned = !input.force && busy && !msg.info.error && !("completed" in msg.info.time) - if (liveOwned) { - skippedLive += 1 - continue - } - yield* sessions.removeMessage({ sessionID: input.sessionID, messageID: msg.info.id }) - removed.push(msg.info.id) - } - if (removed.length > 0 || skippedLive > 0 || skippedError > 0) - elog.info("empty-residue-assistants-cleanup", { - sessionID: input.sessionID, - parentMessageID: input.parentMessageID, - removed, - skippedLive, - skippedError, - }) - return removed - }) - const recovery = Effect.fn("SessionPrompt.recovery")(function* (input: { sessionID: SessionID; agentID?: string; allowBusy?: boolean }) { if (!input.allowBusy && (yield* status.get(input.sessionID)).type !== "idle") return [] const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID ?? "main" }) @@ -3612,26 +3562,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return candidates }) - const abandonRecoveredAssistant = Effect.fn("SessionPrompt.abandonRecoveredAssistant")(function* (input: { - sessionID: SessionID - assistantMessageID: MessageID - agentID?: string - }) { - const messages = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID ?? "main" }) - const message = messages.find((item) => item.info.id === input.assistantMessageID) - if (!message || message.info.role !== "assistant") return - // Empty residue must not get an Abandoned-as-resumed stamp — that would fake a continue. - // Shells are deleted by cleanup. - if (isEmptyAssistantResidue(message.info, message.parts)) return - // Already completed with error => idempotent skip - if ("completed" in message.info.time && message.info.error) return - yield* sessions.updateMessage({ - ...message.info, - time: { ...message.info.time, completed: message.info.time.completed ?? Date.now() }, - error: new MessageV2.AbortedError({ message: "Abandoned: resumed as a new assistant turn" }).toObject(), - }) - }) - const runLoop: ( sessionID: SessionID, agentID?: string, @@ -6236,16 +6166,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return result }) - /** - * Resume has exactly two launch paths (no cleanup-only that skips runLoop): - * - **tool-resume**: target assistant has useful parts (tool / non-synthetic text / non-empty reasoning) - * → abandon + `resumeFrom` continue that assistant. - * - **user-resume**: target has no such parts (dirty empty shells), or the recovery target is a - * trailing user (D16f) → delete empty residue under that user, re-run from that user (no assistant prefill). - * Classification uses only the assistant the client clicked; empty shells are dirty data - * cleaned during user-resume, not a third product path. - * busy / vanished target → reject (no removeMessage). - */ + // Preserve the reviewed target shape through admission; both plans trim and restart from the original user. type ResumePlan = | { action: "tool-resume"; assistantMessageID: MessageID; parentMessageID: MessageID } | { action: "user-resume"; parentMessageID: MessageID; assistantMessageID?: MessageID; strictTail?: boolean } @@ -6334,7 +6255,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return user }) - /** Launch: tool-resume continues the assistant; user-resume re-runs from the parent user. Both start runLoop. */ + /** Trim only after exclusive target validation; the new output is never a prefill. */ const launchResume = Effect.fn("SessionPrompt.launchResume")(function* (input: { sessionID: SessionID agentID: string @@ -6342,6 +6263,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the titleLocale?: string model?: ResumeTurnInput["model"] plan: Exclude + attempt?: HostModelTransport.ExecutionAttempt mode: "ensure" | "start" waitForAdmission?: boolean }) { @@ -6361,11 +6283,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the const strictTail = plan.action === "user-resume" && plan.strictTail === true // [C001] start() forks work; HTTP must wait for admission handshake, not the full turn. const admission = yield* Deferred.make | Session.BusyError>() + let trim: ReturnType | undefined + let trimModel: Provider.Model | undefined - const admit = Effect.gen(function* () { - yield* Effect.yieldNow - const beforeAdmission = ResumeTestHooks.beforeAdmissionRecheck - if (beforeAdmission) yield* beforeAdmission() + const execution = input.attempt ? { + attempt: input.attempt, + directory: (yield* InstanceState.context).directory, + sessionID: input.sessionID, + userMessageID: plan.parentMessageID, + } : undefined + const outcome = yield* Deferred.make>() + let activation: HostModelTransport.ExecutionActivation | void + const prepare = Effect.gen(function* () { if (yield* resumeInputPending(input.sessionID)) { const err = new Session.BusyError(input.sessionID) yield* Deferred.fail(admission, err) @@ -6395,8 +6324,40 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* Deferred.fail(admission, err) throw err } - yield* Deferred.succeed(admission, void 0) + trim = yield* Effect.sync(() => MessageV2.planResumeTrim(tailMsgs, plan.parentMessageID)).pipe( + Effect.catchCause(() => Effect.sync(() => { throw new NotFoundError({ message: "No safe resume history boundary" }) })), + ) + const parent = tailMsgs[parentIdx].info as MessageV2.User + trimModel = yield* getModel( + input.model ? ProviderID.make(input.model.providerID) : parent.model.providerID, + input.model ? ModelID.make(input.model.modelID) : parent.model.modelID, + input.sessionID, + parent, + ) + const converted = yield* MessageV2.toModelMessagesEffect(trim.messages, trimModel, { stripMedia: true }) + yield* Effect.sync(() => MessageV2.assertResumeModelBoundary(converted)).pipe( + Effect.catchCause(() => Effect.sync(() => { throw new NotFoundError({ message: "No complete resume tool-result boundary" }) })), + ) + }) + const admit = Effect.gen(function* () { + yield* Effect.yieldNow + const beforeAdmission = ResumeTestHooks.beforeAdmissionRecheck + if (beforeAdmission) yield* beforeAdmission() + yield* prepare + const hostInput = execution ? { + ...execution, + target: plan.assistantMessageID ? { assistantMessageID: plan.assistantMessageID } : { userMessageID: plan.parentMessageID }, + } : undefined + if (hostInput) { + yield* Effect.promise(() => Promise.resolve(HostModelTransport.prepareExecution(hostInput))) + yield* prepare + } + yield* Effect.uninterruptible(Effect.gen(function* () { + if (hostInput) activation = yield* Effect.sync(() => HostModelTransport.admitExecution(hostInput)) + yield* Deferred.succeed(admission, void 0) + })) }).pipe( + Effect.onExit((exit) => Exit.isFailure(exit) ? Deferred.succeed(outcome, Exit.failCause(exit.cause)).pipe(Effect.asVoid) : Effect.void), Effect.ensuring( Deferred.done(admission, Exit.fail(new NotFoundError({ message: "Resume admission did not complete" }))).pipe( Effect.asVoid, @@ -6406,113 +6367,59 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const work = Effect.gen(function* () { + if (activation) yield* Effect.promise(activation.beforeWork) const afterAdmission = ResumeTestHooks.afterAdmissionBeforeCleanup if (afterAdmission) yield* afterAdmission() - return yield* (plan.action === "user-resume" - ? Effect.gen(function* () { - // [C003] strict trailing-user must NOT pre-clean: deleting a later - // empty assistant would manufacture a legal tail after admission. - // Empty-shell user-resume still clears residue under parent so - // lastAssistant is not a dirty shell blocking re-dispatch. - if (!strictTail) { - yield* cleanupEmptyResidueAssistants({ - sessionID: input.sessionID, - agentID: input.agentID, - parentMessageID: plan.parentMessageID, - sessionBusy: false, - preserveError: false, - }) - } - // [C003] Force cleanup runs only after successful runLoop return so a later - // step-0 strict reject cannot delete a subsequent empty assistant it did not create. - const result = yield* runLoop( - input.sessionID, - input.agentID, - input.task_id, - input.titleLocale, - false, - undefined, - input.model, - true, - // user-resume re-runs from the parent user message — trusted user source. - "user", - plan.parentMessageID, - strictTail, - ) - yield* cleanupEmptyResidueAssistants({ - sessionID: input.sessionID, - agentID: input.agentID, - parentMessageID: plan.parentMessageID, - force: true, - preserveError: true, - }).pipe( - Effect.catchCause((cause) => - elog.warn("empty-residue-assistants-cleanup-failed", { - sessionID: input.sessionID, - parentMessageID: plan.parentMessageID, - phase: "after-success", - cause, - }), - ), - ) - return result - }) - : Effect.gen(function* () { - yield* cleanupEmptyResidueAssistants({ - sessionID: input.sessionID, - agentID: input.agentID, - parentMessageID: plan.parentMessageID, - sessionBusy: false, - preserveError: true, - }) - yield* abandonRecoveredAssistant({ - sessionID: input.sessionID, - assistantMessageID: plan.assistantMessageID, - agentID: input.agentID, - }) - return yield* runLoop( - input.sessionID, - input.agentID, - input.task_id, - input.titleLocale, - false, - plan.assistantMessageID, - input.model, - // tool-resume continues an assistant turn — not a user-source turn. - undefined, - "hook", - plan.parentMessageID, - ) - }).pipe( - Effect.ensuring( - abandonRecoveredAssistant({ - sessionID: input.sessionID, - assistantMessageID: plan.assistantMessageID, - agentID: input.agentID, - }).pipe( - Effect.catchCause((cause) => - elog.warn("recovered-assistant-abandon-failed", { - sessionID: input.sessionID, - messageID: plan.assistantMessageID, - cause, - }), - ), - ), - ), - )) - }) + if (!trim || !trimModel) throw new Error("Resume trim was not admitted") + for (const messageID of trim.removeMessages) yield* sessions.removeMessage({ sessionID: input.sessionID, messageID }) + for (const part of trim.removeParts) yield* sessions.removePart({ sessionID: input.sessionID, messageID: part.messageID, partID: part.id }) + for (const part of trim.updateParts) yield* sessions.updatePart(part) + for (const message of trim.updateMessages) yield* sessions.updateMessage(message) + const committed = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID }) + MessageV2.assertResumeModelBoundary(yield* MessageV2.toModelMessagesEffect(committed, trimModel, { stripMedia: true })) + return yield* runLoop( + input.sessionID, + input.agentID, + input.task_id, + input.titleLocale, + false, + undefined, + input.model, + true, + plan.action === "user-resume" ? "user" : "hook", + plan.parentMessageID, + strictTail, + ) + }).pipe( + Effect.provideService(HostModelTransport.ExecutionScope, execution), + Effect.onExit((exit) => Deferred.succeed(outcome, exit).pipe(Effect.asVoid)), + ) // [R003] Exclusive admission: never join an unrelated run (ensureRunning would). // ensure mode waits for work result (sync resume()); start mode forks (HTTP 202 after handshake). - if (input.mode === "ensure") { + if (input.mode === "ensure" && !execution) { return yield* state.ensureExclusive(input.sessionID, input.agentID, resumeInterrupt, admit.pipe(Effect.andThen(work))) } - if (!input.waitForAdmission && !strictTail) { + if (!input.waitForAdmission && !strictTail && !execution) { yield* state.start(input.sessionID, input.agentID, resumeInterrupt, work, admit) return undefined } - const owned = yield* state.startOwned(input.sessionID, input.agentID, resumeInterrupt, work, admit) - if (input.waitForAdmission || strictTail) { + const owned = yield* Effect.uninterruptible(Effect.gen(function* () { + const owned = yield* state.startOwned(input.sessionID, input.agentID, resumeInterrupt, work.pipe(Effect.interruptible), admit.pipe(Effect.interruptible)) + if (execution) yield* owned.awaitExit.pipe( + Effect.andThen(Effect.sync(() => HostModelTransport.finishExecution(execution.attempt))), + Effect.ensuring(Deferred.succeed(outcome, Exit.die(new Error("Resume execution ended before work"))).pipe(Effect.asVoid)), + Effect.forkIn(scope), + ) + return owned + })) + if (input.mode === "ensure") { + yield* owned.awaitExit + const exit = yield* Deferred.await(outcome) + if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause) + return exit.value + } + if (input.waitForAdmission || strictTail || execution) { const handshake = yield* Deferred.await(admission).pipe( Effect.timeout("5 seconds"), Effect.exit, @@ -6592,6 +6499,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the titleLocale: input.titleLocale, model: input.model, plan, + attempt: input.attempt, mode: "ensure", }) if (launched === undefined) { @@ -6657,6 +6565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the titleLocale: input.titleLocale, model: input.model, plan, + attempt: input.attempt, mode: "start", waitForAdmission: input.waitForAdmission, }) @@ -6727,6 +6636,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the titleLocale: input.titleLocale, model: input.model, plan, + attempt: input.attempt, // Join the Runner so runTurn can settle registry + notify on exit. mode: "ensure", }).pipe( diff --git a/packages/cli/src/session/run-state.ts b/packages/cli/src/session/run-state.ts index 51da9bc9f5..0be92ea888 100644 --- a/packages/cli/src/session/run-state.ts +++ b/packages/cli/src/session/run-state.ts @@ -18,7 +18,7 @@ export interface Interface { onInterrupt: Effect.Effect, work: Effect.Effect, admit?: Effect.Effect, - ) => Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect }, Session.BusyError> + ) => Effect.Effect<{ readonly runId: number; readonly interruptOwned: Effect.Effect; readonly awaitExit: Effect.Effect }, Session.BusyError> readonly cancel: (sessionID: SessionID) => Effect.Effect readonly cancelActor: (sessionID: SessionID, agentID: string) => Effect.Effect readonly ensureRunning: ( diff --git a/packages/cli/test/effect/runner.test.ts b/packages/cli/test/effect/runner.test.ts index 996d20af61..9f5a78e758 100644 --- a/packages/cli/test/effect/runner.test.ts +++ b/packages/cli/test/effect/runner.test.ts @@ -4,6 +4,93 @@ import { Runner } from "../../src/effect" import { it } from "../lib/effect" describe("Runner", () => { + for (const mode of ["startOwned", "start", "ensureExclusive", "ensureRunning"] as const) { + it.live(`[TP-RUN-R4-08] ${mode} publishes busy before work and idle only after cancellation finalizers`, Effect.gen(function* () { + const scope = yield* Scope.Scope + const events: string[] = [] + const started = yield* Deferred.make() + const finalizing = yield* Deferred.make() + const release = yield* Deferred.make() + const runner = Runner.make(scope, { + onBusy: Effect.sync(() => { events.push("busy") }), + onIdle: Effect.sync(() => { events.push("idle") }), + }) + const launched = yield* runner[mode](Effect.gen(function* () { + events.push("work") + yield* Deferred.succeed(started, undefined) + yield* Effect.never + }).pipe(Effect.ensuring(Deferred.succeed(finalizing, undefined).pipe(Effect.andThen(Deferred.await(release)))))).pipe(Effect.forkChild) + yield* Deferred.await(started) + const beforeRefusal = [...events] + const refused = yield* runner.startOwned(Effect.void).pipe(Effect.exit) + const afterRefusal = [...events] + const cancelling = yield* runner.cancel.pipe(Effect.forkChild) + yield* Deferred.await(finalizing) + const cancellingBusy = runner.busy + const duringFinalizer = [...events] + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(cancelling) + yield* Fiber.await(launched) + expect(beforeRefusal).toEqual(["busy", "work"]) + expect(Exit.isFailure(refused)).toBe(true) + expect(afterRefusal).toEqual(["busy", "work"]) + expect(cancellingBusy).toBe(true) + expect(duringFinalizer).toEqual(["busy", "work"]) + expect(runner.busy).toBe(false) + expect(events).toEqual(["busy", "work", "idle"]) + })) + } + it.live("[TP-RUN-R4-08] owned exit settles when cancelled before the work starts", Effect.gen(function* () { + const scope = yield* Scope.Scope + const runner = Runner.make(scope) + let started = false + let finalized = false + const owned = yield* runner.startOwned(Effect.sync(() => { started = true }).pipe( + Effect.ensuring(Effect.sync(() => { finalized = true })), + )) + yield* owned.interruptOwned + yield* owned.awaitExit.pipe(Effect.timeout("1 second")) + expect(started).toBe(false) + expect(finalized).toBe(false) + expect(runner.busy).toBe(false) + })) + + it.live("[TP-RUN-R4-08] owned exit waits for finalizers and cannot interrupt the next owner", Effect.gen(function* () { + const scope = yield* Scope.Scope + const runner = Runner.make(scope) + const started = yield* Deferred.make() + const finalizing = yield* Deferred.make() + const release = yield* Deferred.make() + const owned = yield* runner.startOwned(Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(finalizing, undefined).pipe(Effect.andThen(Deferred.await(release)))), + )) + yield* Deferred.await(started) + let exited = false + const watcher = yield* owned.awaitExit.pipe(Effect.andThen(Effect.sync(() => { exited = true })), Effect.forkChild) + const cancelling = yield* owned.interruptOwned.pipe(Effect.forkChild) + yield* Deferred.await(finalizing) + expect(exited).toBe(false) + expect(runner.busy).toBe(true) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(cancelling) + yield* Fiber.join(watcher) + expect(exited).toBe(true) + const replacement = yield* runner.startOwned(Effect.never) + yield* owned.interruptOwned + expect(runner.busy).toBe(true) + expect(replacement.runId).not.toBe(owned.runId) + yield* replacement.interruptOwned + yield* replacement.awaitExit + })) + + for (const failure of [false, true]) it.live(`[TP-RUN-R4-08] owned exit observes ${failure ? "failure" : "success"} without an admission waiter`, Effect.gen(function* () { + const scope = yield* Scope.Scope + const runner = Runner.make(scope) + const owned = yield* runner.startOwned(failure ? Effect.fail("failed") : Effect.void) + yield* owned.awaitExit.pipe(Effect.timeout("1 second")) + expect(runner.busy).toBe(false) + })) it.live("start reports busy as a failure instead of a defect", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) diff --git a/packages/cli/test/server/session-recovery.test.ts b/packages/cli/test/server/session-recovery.test.ts index 792a104c34..a7225e803c 100644 --- a/packages/cli/test/server/session-recovery.test.ts +++ b/packages/cli/test/server/session-recovery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import { Effect, Fiber } from "effect" import { createOpencodeClient } from "@mimo-ai/sdk/v2" import { AppRuntime } from "../../src/effect/app-runtime" @@ -6,6 +6,8 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { HostModelTransport } from "../../src/provider/host-transport" import { SessionStatus } from "../../src/session/status" import { SessionRunState } from "../../src/session/run-state" import { ActorRegistry } from "../../src/actor/registry" @@ -28,6 +30,13 @@ afterEach(async () => { describe("session turn recovery routes", () => { test("lists the latest incomplete assistant and accepts resume without a new prompt", async () => { await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: textStopResponse("route recovered answer") }]) + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) const result = await Instance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise(Effect.gen(function* () { @@ -55,18 +64,9 @@ describe("session turn recovery routes", () => { providerID: ProviderID.make("test"), time: { created: Date.now() }, }) + yield* sessions.updateMessage({ ...user, model: { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") } }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: user.id, type: "text", text: "Original route request" }) const app = Server.Default().app - const errors: unknown[] = [] - let resolveError!: () => void - const errorSeen = new Promise((resolve) => { - resolveError = resolve - }) - const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => { - if (event.properties.sessionID === session.id) { - errors.push(event.properties.error) - resolveError() - } - }) const query = `?directory=${encodeURIComponent(tmp.path)}` const resumeQuery = `${query}&titleLocale=fr-FR` const listed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/recovery${query}`))) @@ -79,18 +79,19 @@ describe("session turn recovery routes", () => { Promise.resolve(app.request(`/session/${session.id}/turn/${MessageID.ascending()}/resume${resumeQuery}`, { method: "POST" })), ) const resumed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/turn/${assistant.id}/resume${resumeQuery}`, { method: "POST" }))) - yield* Effect.promise(() => - Promise.race([errorSeen, new Promise((resolve) => setTimeout(resolve, 10_000))]), - ) - // post-resume observable signals (not pre-resume candidates) - yield* Effect.sleep("150 millis") + for (let i = 0; i < 100; i++) { + const current = yield* sessions.messages({ sessionID: session.id }) + if (current.some((message) => message.info.role === "assistant" && message.info.time.completed && message.parts.some((part) => part.type === "text" && part.text === "route recovered answer"))) { + if ((yield* (yield* SessionStatus.Service).get(session.id)).type === "idle") break + } + yield* Effect.sleep("20 millis") + } const afterListed = yield* Effect.promise(() => Promise.resolve(app.request(`/session/${session.id}/recovery${query}`)), ) const afterCandidates = yield* Effect.promise(() => afterListed.json() as Promise>, ) - unsubscribe() const after = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) const abandoned = after.find((item) => item.info.id === assistant.id)?.info const abandonedAssistant = abandoned?.role === "assistant" ? abandoned : undefined @@ -100,7 +101,7 @@ describe("session turn recovery routes", () => { resumed: resumed.status, missing: missing.status, userID: user.id, - errors, + recovered: after.find((message) => message.parts.some((part) => part.type === "text" && part.text === "route recovered answer")), abandoned: abandonedAssistant, shellRemoved: abandoned === undefined, afterNoLongerListsShell: !afterCandidates.some((c) => c.assistantMessageID === assistant.id), @@ -119,23 +120,23 @@ describe("session turn recovery routes", () => { created: expect.any(Number), }, ]) - // [TP-SR-R21-16] HTTP resume admitted (202) and empty residue shell cleaned + // [TP-SR-R21-27] The empty container is retained; the new answer completes the original user. expect(result.resumed).toBe(202) expect(result.missing).toBe(404) - expect(result.shellRemoved).toBe(true) - // Both must hold for empty-tail resume; not an OR against pre-resume candidates. + expect(result.shellRemoved).toBe(false) expect(result.afterNoLongerListsShell).toBe(true) - // Work proof independent of shellRemoved: session error bus fired (runLoop without provider) - // OR a new assistant message appeared after cleanup. - const newAssistants = result.afterMessages.filter( - (m: { info: { role: string; id: string } }) => m.info.role === "assistant", - ) - expect(result.errors.length > 0 || newAssistants.length > 0).toBe(true) + expect(result.recovered?.info).toMatchObject({ role: "assistant", parentID: result.userID, finish: "stop" }) + expect(stub.captures).toHaveLength(1) + expect(stub.captures[0].messages.at(-1)?.role).toBe("user") if (result.abandoned?.error) { const err = result.abandoned.error as { data?: { message?: string } } const abandonMsg = err.data?.message ?? "" expect(abandonMsg).not.toContain("Abandoned: resumed as a new assistant turn") } + } finally { + await Instance.disposeAll() + await stub.stop() + } }) }) @@ -166,6 +167,215 @@ test("SDK serializes resume titleLocale in the query string", async () => { expect(captured!.body).toBeNull() }) +// [TP-SR-R21-27] [TP-SR-R21-28] [TP-SR-R21-29] [TP-SR-R21-30] +for (const variant of ["text", "tools", "pending", "provider", "protected", "delete-failure", "tool-error"] as const) { + const withTools = variant !== "text" + test(`resume persistently trims consecutive assistant blocks ${variant}`, async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: textStopResponse("recovered trimmed answer") }]) + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) + await Instance.provide({ directory: tmp.path, fn: () => AppRuntime.runPromise(Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "persistent resume trim" }) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), role: "user", sessionID: session.id, agent: "build", + model: { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") }, time: { created: Date.now() }, + }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: user.id, type: "text", text: "Original request" }) + const info = { + role: "assistant" as const, sessionID: session.id, parentID: user.id, agent: "build", mode: "build", + path: { cwd: tmp.path, root: tmp.path }, modelID: ModelID.make("qwen-plus"), providerID: ProviderID.make("alibaba"), + time: { created: Date.now(), completed: Date.now() }, finish: "length", cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + const first = yield* sessions.updateMessage({ ...info, id: MessageID.ascending(), + ...(variant === "tool-error" ? { error: { name: "APIError" as const, data: { message: "upstream failed", isRetryable: false } } } : {}), + }) + const retained: MessageV2.Part[] = [] + if (withTools) { + for (const callID of ["retained-call-a", "retained-call-b"]) { + retained.push(yield* sessions.updatePart({ + id: PartID.ascending(), sessionID: session.id, messageID: first.id, type: "tool", tool: "bash", callID, + ...(variant === "provider" ? { metadata: { providerExecuted: true } } : {}), + state: { status: "completed", input: { command: "never-replay" }, output: `OBSERVATION_${callID}`, + title: "done", metadata: {}, time: { start: 1, end: 2 }, + attachments: [{ id: PartID.ascending(), sessionID: session.id, messageID: first.id, type: "file", mime: "text/plain", url: "data:text/plain;base64,YXJ0aWZhY3Q=", filename: "result.txt" }], + }, + })) + } + if (variant === "pending") yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: first.id, + type: "tool", tool: "bash", callID: "partial-input", state: { status: "pending", input: {}, raw: '{"command":' } }) + retained.push(yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: first.id, + type: "text", text: "same-block text before SDK tool results" })) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: first.id, type: "step-start" }) + } + const discarded = yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: first.id, + type: "text", text: "DISCARD_FIRST_TAIL", time: { start: 1, end: 2 } }) + const empty = yield* sessions.updateMessage({ ...info, id: MessageID.ascending() }) + const target = yield* sessions.updateMessage({ ...info, id: MessageID.ascending(), ...(variant === "protected" ? { structured: null } : {}) }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: target.id, + type: "reasoning", text: "DISCARD_LAST_TAIL", time: { start: 1, end: 2 } }) + const before = yield* sessions.messages({ sessionID: session.id }) + const removed: string[] = [] + const removedParts: string[] = [] + const offMessage = Bus.subscribe(MessageV2.Event.Removed, (event) => { if (event.properties.sessionID === session.id) removed.push(event.properties.messageID) }) + const offPart = Bus.subscribe(MessageV2.Event.PartRemoved, (event) => { if (event.properties.sessionID === session.id) removedParts.push(event.properties.partID) }) + yield* prompt.resume({ sessionID: session.id, assistantMessageID: first.id }).pipe(Effect.exit) + expect(yield* sessions.messages({ sessionID: session.id })).toEqual(before) + expect(removed).toEqual([]) + if (["pending", "provider", "protected", "delete-failure"].includes(variant)) { + const failure = variant === "delete-failure" ? spyOn(sessions, "removeMessage").mockImplementation(() => Effect.die(new Error("injected delete failure"))) : undefined + const rejected = yield* prompt.resume({ sessionID: session.id, assistantMessageID: target.id }).pipe(Effect.exit) + failure?.mockRestore() + offMessage(); offPart() + expect(rejected._tag).toBe("Failure") + expect(yield* sessions.messages({ sessionID: session.id })).toEqual(before) + expect(removed).toEqual([]) + expect(removedParts).toEqual([]) + expect(stub.captures).toHaveLength(0) + return + } + const result = yield* prompt.resume({ sessionID: session.id, assistantMessageID: target.id }) + offMessage(); offPart() + const after = yield* sessions.messages({ sessionID: session.id }) + expect(after.some((message) => message.info.id === target.id)).toBe(false) + expect(removed).toContain(target.id) + expect(after.find((message) => message.info.id === empty.id)).toEqual(before.find((message) => message.info.id === empty.id)) + expect(after.flatMap((message) => message.parts).some((part) => part.id === discarded.id)).toBe(false) + if (withTools) { + expect(after.find((message) => message.info.id === first.id)?.parts).toEqual(retained) + expect(removedParts).toContain(discarded.id) + } else expect(removed).toContain(first.id) + expect(after.filter((message) => message.info.role === "user").map((message) => message.info.id)).toEqual([user.id]) + expect(result.info).toMatchObject({ parentID: user.id }) + expect(stub.captures).toHaveLength(1) + const request = stub.captures[0].messages + expect(request.at(-1)?.role).not.toBe("assistant") + expect(JSON.stringify(request)).not.toContain("DISCARD_") + if (withTools) for (const callID of ["retained-call-a", "retained-call-b"]) expect(JSON.stringify(request)).toContain(`OBSERVATION_${callID}`) + })) }) + } finally { + await Instance.disposeAll() + await stub.stop() + } + }, 30_000) +} + +// [TP-SR-R21-26] [TP-SR-R21-30] [TP-SR-R21-31] [TP-R8-25] [TP-RUN-R4-08] +for (const mode of ["success", "host-reject", "commit-reject", "changed-during-host", "cancel-during-host", "cancel-during-work"] as const) { + test(`resume host attempt lifecycle ${mode}`, async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: textStopResponse("host-bound recovery") }]) + const attempt = { requestId: "resume-request", attemptId: "resume-attempt" } + const prepared: HostModelTransport.ExecutionAdmission[] = [] + const admitted: HostModelTransport.ExecutionAdmission[] = [] + const finished: HostModelTransport.ExecutionAttempt[] = [] + const scopes: HostModelTransport.ModelCallScope[] = [] + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + HostModelTransport.set({ + userMessage() {}, + async prepareExecution(input) { + prepared.push(input) + if (mode !== "cancel-during-work") entered.resolve() + if (mode === "host-reject") throw new Error("host refused preparation") + if (mode === "changed-during-host" || mode === "cancel-during-host") await release.promise + }, + admitExecution(input) { + if (mode === "commit-reject") throw new Error("host refused commit") + admitted.push(input) + return { beforeWork: async () => { + if (mode === "cancel-during-work") { entered.resolve(); await release.promise } + } } + }, + finishExecution(input) { finished.push(input) }, + modelCall(input, call) { scopes.push(input); return call() }, + }) + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) + await Instance.provide({ directory: tmp.path, fn: () => AppRuntime.runPromise(Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "host attempt resume" }) + const user = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: session.id, + agent: "build", model: { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") }, time: { created: Date.now() } }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: user.id, type: "text", text: "Original host input" }) + const target = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "assistant", sessionID: session.id, + parentID: user.id, agent: "build", mode: "build", modelID: ModelID.make("qwen-plus"), providerID: ProviderID.make("alibaba"), + path: { cwd: tmp.path, root: tmp.path }, time: { created: Date.now() }, cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: target.id, type: "text", text: "old half answer" }) + const before = yield* sessions.messages({ sessionID: session.id }) + const activity: string[] = [] + const offStatus = Bus.subscribe(SessionStatus.Event.Status, (event) => { + if (event.properties.sessionID === session.id) activity.push(event.properties.status.type) + }) + const app = Server.Default().app + const request = Promise.resolve(app.request(`/session/${session.id}/resume?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ assistantMessageID: target.id, attempt }), + })) + yield* Effect.promise(() => entered.promise).pipe(Effect.timeout("5 seconds")) + expect(prepared).toEqual([{ attempt, directory: tmp.path, sessionID: session.id, userMessageID: user.id, target: { assistantMessageID: target.id } }]) + expect(finished).toEqual([]) + if (mode === "changed-during-host") { + yield* sessions.updateMessage({ ...user, id: MessageID.ascending(), time: { created: Date.now() + 1 } }) + release.resolve() + } + if (mode === "cancel-during-work") { + expect((yield* Effect.promise(() => request)).status).toBe(202) + const snapshot = yield* Effect.promise(() => Promise.resolve(app.request(`/session/status?directory=${encodeURIComponent(tmp.path)}`)).then(response => response.json())) + expect(snapshot[session.id]).toEqual({ type: "busy" }) + expect(activity).toEqual(["busy"]) + expect(scopes).toEqual([]) + expect(stub.captures).toHaveLength(0) + } + if (mode === "cancel-during-host" || mode === "cancel-during-work") { + yield* prompt.cancel(session.id) + release.resolve() + } + const response = yield* Effect.promise(() => request) + for (let i = 0; i < 100 && finished.length === 0; i++) yield* Effect.sleep("20 millis") + expect(finished).toEqual([attempt]) + offStatus() + if (mode === "cancel-during-work") { + expect(activity.at(-1)).toBe("idle") + expect(yield* SessionStatus.Service.use(status => status.get(session.id))).toEqual({ type: "idle" }) + } + const after = yield* sessions.messages({ sessionID: session.id }) + expect(admitted).toEqual(mode === "success" || mode === "cancel-during-work" ? prepared : []) + if (mode === "success") { + expect(response.status).toBe(202) + expect(stub.captures).toHaveLength(1) + expect(scopes).toEqual([expect.objectContaining({ executionAttempt: attempt, directory: tmp.path, sessionID: session.id, userMessageID: user.id, assistantMessageID: expect.any(String) })]) + expect(scopes[0].assistantMessageID).not.toBe(target.id) + expect(after.some((message) => message.info.id === target.id)).toBe(false) + } else { + expect(response.status).toBe(mode === "cancel-during-work" ? 202 : 404) + expect(scopes).toEqual([]) + expect(stub.captures).toHaveLength(0) + expect(after.filter((message) => before.some((old) => old.info.id === message.info.id))).toEqual(before) + } + })) }) + } finally { + release.resolve() + await Instance.disposeAll() + HostModelTransport.set(undefined) + await stub.stop() + } + }, 30_000) +} + const finalDeliveries = [ { structured: { answer: 4 } }, { structured: false }, @@ -343,9 +553,12 @@ describe("empty stop recovery matrix", () => { }) const beforeDeliveries = (yield* sessions.messages({ sessionID: session.id, agentID: "main" })) .filter((message) => message.info.role === "assistant") + const currentUser = yield* sessions.updateMessage({ ...user, id: MessageID.ascending(), time: { created: Date.now() } }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: currentUser.id, + type: "text", text: "Answer the next question." }) const created = Date.now() + 1 const assistant = yield* sessions.updateMessage({ - ...assistantInfo, id: MessageID.ascending(), + ...assistantInfo, id: MessageID.ascending(), parentID: currentUser.id, time: { created, ...(completed ? { completed: created } : {}) }, }) if (content) yield* sessions.updatePart({ @@ -385,12 +598,13 @@ describe("empty stop recovery matrix", () => { beforeDeliveries, afterDeliveries: after.filter((message) => beforeDeliveries.some((before) => before.info.id === message.info.id)), users: after.filter((message) => message.info.role === "user").map((message) => message.info.id), - userID: user.id, + userID: currentUser.id, + previousUserID: user.id, } })), }) const shouldRecover = !content || error - expect(result.users).toEqual([result.userID]) + expect(result.users).toEqual([result.previousUserID, result.userID]) expect(result.afterDeliveries).toEqual(result.beforeDeliveries) expect(result.recoveryStatus).toBe(200) expect(result.candidates.length).toBe(shouldRecover ? 1 : 0) @@ -399,7 +613,7 @@ describe("empty stop recovery matrix", () => { expect(result.plan?.action).toBe(content ? "tool-resume" : "user-resume") expect(result.recovered?.info).toMatchObject({ role: "assistant", parentID: result.userID }) expect(result.providerCalls).toBe(1) - expect(result.shellRemoved).toBe(!content) + expect(result.shellRemoved).toBe(output !== "empty") } else { expect(result.shellRemoved).toBe(false) expect(result.providerCalls).toBe(0) @@ -511,6 +725,14 @@ describe("trailing user recovery target", () => { // not just HTTP 202, and not a pre-route independent recovery() read. test("POST /resume with empty body resumes latest recovery candidate", async () => { await using tmp = await tmpdir({ git: true }) + const release = Promise.withResolvers() + const stub = startScriptedLLMServer([{ lines: textStopResponse("bodyless recovery"), beforeReply: () => release.promise }]) + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) const result = await Instance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise(Effect.gen(function* () { @@ -526,6 +748,8 @@ describe("trailing user recovery target", () => { model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, time: { created: Date.now() }, }) + yield* sessions.updateMessage({ ...user, model: { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") } }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: user.id, type: "text", text: "Original route input" }) // Capture the plan that /resume actually resolves (R004) let resolvedPlan: { action: string; assistantMessageID?: string; parentMessageID?: string } | undefined ResumeTestHooks.onPlanResolved = (plan) => { resolvedPlan = plan } @@ -553,6 +777,15 @@ describe("trailing user recovery target", () => { method: "POST", })), ) + release.resolve() + for (let i = 0; i < 100; i++) { + const current = yield* sessions.messages({ sessionID: session.id }) + if (current.some((message) => message.info.role === "assistant" && message.info.time.completed && message.parts.some((part) => part.type === "text" && part.text === "bodyless recovery"))) break + yield* Effect.sleep("20 millis") + } + const after = yield* sessions.messages({ sessionID: session.id }) + expect(after.some((message) => message.parts.some((part) => part.type === "text" && part.text === "bodyless recovery"))).toBe(true) + expect(after.filter((message) => message.info.role === "user").map((message) => message.info.id)).toEqual([user.id]) return { ok: ok.status, none: none.status, @@ -568,13 +801,26 @@ describe("trailing user recovery target", () => { expect(result.planParent).toBe(result.userParentId) expect(result.ok).toBe(202) expect(result.none).toBe(404) - // second resume on already-busy session after first 202 → 409 - expect(result.noBody === 202 || result.noBody === 409).toBe(true) + // The real provider response stays gated until after the duplicate request. + expect(result.noBody).toBe(409) + expect(stub.captures).toHaveLength(1) + } finally { + release.resolve() + await Instance.disposeAll() + await stub.stop() + } }) // [TP-SR-R21-10] resumeUser 202=完成准入;缺失目标 404(非 false 202)。 test("POST /resume with userMessageID validates trailing user", async () => { await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([{ lines: textStopResponse("explicit user recovery") }]) + try { + await Bun.write(`${tmp.path}/mimocode.json`, JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${stub.origin}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + })) const result = await Instance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise(Effect.gen(function* () { @@ -588,6 +834,8 @@ describe("trailing user recovery target", () => { model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, time: { created: Date.now() }, }) + yield* sessions.updateMessage({ ...user, model: { providerID: ProviderID.make("alibaba"), modelID: ModelID.make("qwen-plus") } }) + yield* sessions.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: user.id, type: "text", text: "Original route input" }) const session2 = yield* sessions.create({ title: "resume user missing" }) const app = Server.Default().app const query = `?directory=${encodeURIComponent(tmp.path)}` @@ -606,11 +854,24 @@ describe("trailing user recovery target", () => { body: JSON.stringify({ userMessageID: MessageID.ascending() }), })), ) + for (let i = 0; i < 100; i++) { + const current = yield* sessions.messages({ sessionID: session.id }) + if (current.some((message) => message.info.role === "assistant" && message.info.time.completed && message.parts.some((part) => part.type === "text" && part.text === "explicit user recovery"))) break + yield* Effect.sleep("20 millis") + } + const after = yield* sessions.messages({ sessionID: session.id }) + expect(after.some((message) => message.parts.some((part) => part.type === "text" && part.text === "explicit user recovery"))).toBe(true) + expect(after.filter((message) => message.info.role === "user").map((message) => message.info.id)).toEqual([user.id]) return { ok: ok.status, missing: missing.status } })), }) expect(result.ok).toBe(202) expect(result.missing).toBe(404) + expect(stub.captures).toHaveLength(1) + } finally { + await Instance.disposeAll() + await stub.stop() + } }) // [TP-SR-R21-10][C001] admission 失败必须 404,不得 false 202(resumeUser 准入契约)。 @@ -726,6 +987,12 @@ test("SDK recovery response is union and resumeUser posts body userMessageID", a // [TP-SR-R21-26] await client.session.resumeUser({ sessionID: "ses_test", assistantMessageID: "msg_a" }) expect(calls.at(-1)?.body).toEqual({ assistantMessageID: "msg_a" }) + // [TP-SR-R21-30] Generated SDK must retain the host execution identity. + const attempt = { requestId: "reviewed-resume", attemptId: "attempt-one" } + await client.session.resumeUser({ sessionID: "ses_test", assistantMessageID: "msg_a", attempt }) + expect(calls.at(-1)?.body).toEqual({ assistantMessageID: "msg_a", attempt }) + await client.session.resumeUser({ sessionID: "ses_test", userMessageID: "msg_u2", attempt }) + expect(calls.at(-1)?.body).toEqual({ userMessageID: "msg_u2", attempt }) }) // [TP-SR-R21-26] diff --git a/packages/cli/test/session/invalid-output-continuation.test.ts b/packages/cli/test/session/invalid-output-continuation.test.ts index d1b9473b22..78d17e1c69 100644 --- a/packages/cli/test/session/invalid-output-continuation.test.ts +++ b/packages/cli/test/session/invalid-output-continuation.test.ts @@ -146,10 +146,11 @@ describe("invalid-output continuation — integration", () => { expect(result.candidates).toMatchObject([{ kind: "assistant", assistantMessageID: assistant?.info.id }]) expect(result.resumeStatus).toBe(202) expect(result.providerCalls).toBe(2) - expect(result.afterResume.some((message) => message.info.id === assistant?.info.id)).toBe(false) + // [TP-SR-R21-27] The original empty container stays outside the request, not deleted merely for being empty. + expect(result.afterResume.find((message) => message.info.id === assistant?.info.id)).toEqual(assistant) const users = result.afterResume.filter((message) => message.info.role === "user") expect(users.map((message) => message.info.id)).toEqual(result.atBarrier.filter((message) => message.info.role === "user").map((message) => message.info.id)) - const recovered = result.afterResume.find((message) => message.info.role === "assistant") + const recovered = result.afterResume.find((message) => message.info.role === "assistant" && message.info.id !== assistant?.info.id) expect(recovered?.info).toMatchObject({ role: "assistant", parentID: users[0].info.id, finish: "stop", time: { completed: expect.any(Number) } }) expect(recovered?.parts.some((part) => part.type === "text" && part.text === "recovered answer")).toBe(true) } finally { diff --git a/packages/cli/test/session/message-v2.test.ts b/packages/cli/test/session/message-v2.test.ts index a33eba2f3d..b3285bcb57 100644 --- a/packages/cli/test/session/message-v2.test.ts +++ b/packages/cli/test/session/message-v2.test.ts @@ -82,6 +82,131 @@ function withInputCapabilities( } } +// [TP-SR-R21-27] [TP-SR-R21-28] [TP-SR-R21-30] +describe("resume trim model boundary", () => { + const userID = MessageID.make("resume-user") + const assistantID = MessageID.make("resume-assistant") + const base = { sessionID, messageID: assistantID } + function history(parts: MessageV2.Part[]): MessageV2.WithParts[] { + return [ + { info: userInfo(userID), parts: [{ ...base, id: PartID.make("user-text"), messageID: userID, type: "text", text: "Original input" }] }, + { info: { ...assistantInfo(assistantID, userID), finish: "length" }, parts }, + ] + } + function tool(id: string, providerExecuted = false): MessageV2.ToolPart { + return { ...base, id: PartID.make(id), type: "tool", tool: "bash", callID: id, + metadata: providerExecuted ? { providerExecuted: true } : undefined, + state: { status: "completed", input: { command: "already executed" }, output: "original output", title: "done", metadata: {}, time: { start: 1, end: 2 } }, + } + } + test("legacy tool block keeps text after the call because SDK emits results after the entire block", async () => { + const parts: MessageV2.Part[] = [tool("call-a"), + { ...base, id: PartID.make("after-tool"), type: "text", text: "still before SDK results" }, tool("call-b"), + { ...base, id: PartID.make("trailing-reasoning"), type: "reasoning", text: "same block", time: { start: 1, end: 2 } }, + ] + const input = history(parts) + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.messages).toEqual(input) + expect(trim.removeMessages).toEqual([]) + expect(trim.removeParts).toEqual([]) + const converted = await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true }) + expect(converted.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(converted[1].content).toEqual(expect.arrayContaining([{ type: "text", text: "still before SDK results" }])) + expect(() => MessageV2.assertResumeModelBoundary(converted)).not.toThrow() + }) + test("step-start defines discarded text even when end timestamps say completed", async () => { + const parts: MessageV2.Part[] = [tool("call-a"), tool("call-b"), + { ...base, id: PartID.make("next-block"), type: "step-start" }, + { ...base, id: PartID.make("discard"), type: "text", text: "half answer", time: { start: 1, end: 2 } }, + ] + const input = history(parts) + const original = structuredClone(input) + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.removeParts).toEqual(parts.slice(2)) + expect(trim.messages[1].parts).toEqual(parts.slice(0, 2)) + expect(input).toEqual(original) + MessageV2.assertResumeModelBoundary(await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true })) + }) + test("provider-executed results stay assistant; a synthetic attachment cannot manufacture a safe boundary", async () => { + const part = tool("provider-call", true) + if (part.state.status === "completed") part.state.attachments = [{ ...base, id: PartID.make("attachment"), type: "file", mime: "image/png", url: `data:image/png;base64,${pngBase64}` }] + const trim = MessageV2.planResumeTrim(history([part]), userID) + const converted = await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true }) + expect(converted.at(-1)?.role).toBe("assistant") + expect(converted.at(-1)?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "tool-result", toolCallId: "provider-call" })])) + expect(() => MessageV2.assertResumeModelBoundary(converted)).toThrow("boundary") + expect(trim.removeMessages).toEqual([]) + expect(trim.removeParts).toEqual([]) + }) + test("mixed provider/client group is paired and ends at the client result", async () => { + const trim = MessageV2.planResumeTrim(history([tool("provider-call", true), tool("client-call")]), userID) + const converted = await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true }) + expect(converted.at(-1)?.role).toBe("tool") + MessageV2.assertResumeModelBoundary(converted) + }) + test("pending raw input rejects; running tools get an explicit interruption without changing completed tools", async () => { + const pending: MessageV2.ToolPart = { ...tool("pending"), state: { status: "pending", input: {}, raw: '{"command":' } } + const pendingInput = history([tool("completed"), pending]) + const original = structuredClone(pendingInput) + expect(() => MessageV2.planResumeTrim(pendingInput, userID)).toThrow("execution boundary") + expect(pendingInput).toEqual(original) + const running: MessageV2.ToolPart = { ...tool("running"), state: { status: "running", input: { command: "already started" }, time: { start: 1 }, metadata: { output: "partial output" } } } + const completed = tool("completed") + const trim = MessageV2.planResumeTrim(history([completed, running]), userID) + expect(trim.messages[1].parts[0]).toEqual(completed) + expect(trim.updateParts).toEqual([expect.objectContaining({ id: running.id, state: expect.objectContaining({ status: "error", error: "Tool execution aborted", input: running.state.input, metadata: { output: "partial output", interrupted: true } }) })]) + expect(running.state.status).toBe("running") + const converted = await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true }) + MessageV2.assertResumeModelBoundary(converted) + expect(JSON.stringify(converted)).toContain("partial output") + }) + test("interrupted errors require a persisted complete-call boundary, including after repeated cleanup", async () => { + for (const inputComplete of [undefined, false, true]) { + const state: MessageV2.ToolStateError = { + status: "error", input: {}, inputComplete, error: "interrupted", + metadata: { interrupted: true }, time: { start: 1, end: 2 }, + } + const settled = MessageV2.abortedToolState(state) + expect(settled.inputComplete).toBe(inputComplete) + const input = history([{ ...tool("interrupted"), state: settled }]) + const original = structuredClone(input) + if (inputComplete !== true) { + expect(() => MessageV2.planResumeTrim(input, userID)).toThrow("execution boundary") + } else { + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.updateParts).toEqual([]) + MessageV2.assertResumeModelBoundary(await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true })) + } + expect(input).toEqual(original) + } + }) + test("APIError on tool carrier cannot silently hide completed observations", async () => { + const input = history([tool("completed")]) + input[1].info = { ...assistantInfo(assistantID, userID), error: { name: "APIError", data: { message: "unavailable", isRetryable: false } } } + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.updateMessages).toHaveLength(1) + expect(trim.messages[1].parts).toEqual(input[1].parts) + const converted = await MessageV2.toModelMessages(trim.messages, model, { stripMedia: true }) + MessageV2.assertResumeModelBoundary(converted) + expect(JSON.stringify(converted)).toContain("original output") + }) + test("empty containers stay durable but never become model input", async () => { + const input = history([{ ...base, id: PartID.make("step"), type: "step-start" }, + { ...base, id: PartID.make("placeholder"), type: "text", text: "ignored placeholder", ignored: true }]) + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.messages).toEqual(input) + expect(trim.removeMessages).toEqual([]) + expect((await MessageV2.toModelMessages(trim.messages, model)).map((message) => message.role)).toEqual(["user"]) + }) + for (const protectedInfo of [{ summary: true }, { structured: null }, { finish: "stop" }]) { + test(`protect delivered output ${JSON.stringify(protectedInfo)}`, () => { + const input = history([{ ...base, id: PartID.make("text"), type: "text", text: "delivered" }]) + input[1].info = { ...assistantInfo(assistantID, userID), ...protectedInfo } + expect(() => MessageV2.planResumeTrim(input, userID)).toThrow("protected output") + }) + } +}) + function userInfo(id: string): MessageV2.User { return { id, diff --git a/packages/cli/test/session/prompt-effect.test.ts b/packages/cli/test/session/prompt-effect.test.ts index 60eaea5cdc..ad028fcb62 100644 --- a/packages/cli/test/session/prompt-effect.test.ts +++ b/packages/cli/test/session/prompt-effect.test.ts @@ -2289,17 +2289,10 @@ it.live("resume continues an incomplete assistant without creating or rewriting const after = yield* sessions.messages({ sessionID: chat.id }) expect(after.filter((message) => message.info.role === "user")).toHaveLength(1) - expect(after.length).toBe(before.length + 1) - expect(after.find((message) => message.info.id === seeded.assistant.id)?.info).toMatchObject(seeded.assistant) - // tool-resume must stamp Abandoned-as-resumed on the continued assistant - const seededAfter = after.find((message) => message.info.id === seeded.assistant.id)?.info - const abandonMsg = - seededAfter && seededAfter.role === "assistant" && seededAfter.error - ? ((seededAfter.error as { data?: { message?: string }; message?: string }).data?.message ?? - (seededAfter.error as { message?: string }).message ?? - "") - : "" - expect(abandonMsg).toContain("Abandoned: resumed as a new assistant turn") + // [TP-SR-R21-27] Pure assistant tails are durably removed, not reused as prefill. + expect(after.length).toBe(before.length) + expect(after.find((message) => message.info.id === seeded.assistant.id)).toBeUndefined() + expect((requests[0]?.messages as { role: string }[]).at(-1)?.role).toBe("user") expect(result.info.role).toBe("assistant") expect(result.info.id).not.toBe(seeded.assistant.id) expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true) @@ -2359,24 +2352,10 @@ it.live( expect(JSON.stringify(requests[0]?.messages ?? [])).not.toContain("Abandoned: resumed as a new assistant turn") const after = yield* sessions.messages({ sessionID: chat.id }) - expect(after.find((m) => m.info.id === shell.id)).toBeUndefined() + // [TP-SR-R21-27] Empty output containers are not a reason to delete history. + expect(after.find((m) => m.info.id === shell.id)?.info).toMatchObject(shell) + expect(after.find((m) => m.info.id === shell.id)?.parts).toEqual([]) expect(after.filter((m) => m.info.role === "user")).toHaveLength(1) - const assistants = after.filter((m) => m.info.role === "assistant") - expect(assistants.length).toBeGreaterThan(0) - // success path: no stacked empty residue under parent - expect( - assistants.every( - (m) => - m.info.role !== "assistant" || - m.parts.some( - (part) => - (part.type === "text" && part.text.trim().length > 0) || - part.type === "tool" || - (part.type === "reasoning" && part.text.trim().length > 0), - ) || - Boolean(m.info.role === "assistant" && m.info.error), - ), - ).toBe(true) expect(result.info.role).toBe("assistant") expect(result.parts.some((part) => part.type === "text" && part.text === "found 2 resumes")).toBe(true) }), diff --git a/packages/cli/test/session/tool-gate-cancel.test.ts b/packages/cli/test/session/tool-gate-cancel.test.ts index 9dfaaceb8d..4020de655a 100644 --- a/packages/cli/test/session/tool-gate-cancel.test.ts +++ b/packages/cli/test/session/tool-gate-cancel.test.ts @@ -10,7 +10,7 @@ import { ToolGate } from "../../src/tool/gate" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { startScriptedLLMServer, toolCallsResponse } from "../lib/scripted-llm-server" +import { startScriptedLLMServer, textStopResponse, toolCallsResponse } from "../lib/scripted-llm-server" const it = testEffect( Layer.mergeAll( @@ -103,7 +103,10 @@ it.live( yield* prompt.cancel(session.id) const result = yield* Fiber.join(running) expect(result.info.role === "assistant" && result.info.error?.name).toBe("MessageAbortedError") - expect(entering.mock.calls.find(([tool, callID]) => tool === "question" && callID === "ask-user")?.[3]?.signal?.aborted).toBe(true) + expect( + entering.mock.calls.find(([tool, callID]) => tool === "question" && callID === "ask-user")?.[3]?.signal + ?.aborted, + ).toBe(true) const pendingAfterStop = yield* questions.list() const toolsAfterStop = (yield* sessions.messages({ sessionID: session.id })) .flatMap((message) => message.parts) @@ -156,3 +159,134 @@ it.live( }), 20000, ) + +// [TP-SR-R21-28] [TP-SR-R21-30] +for (const boundary of ["partial", "complete-empty"] as const) { + it.live( + `Stop preserves the actual tool input boundary (${boundary})`, + () => + Effect.gen(function* () { + const callID = "interrupted-input" + const tool = boundary === "partial" ? "bash" : "plan_exit" + const lines = toolCallsResponse([ + { id: callID, name: tool, args: boundary === "partial" ? '{"command":' : "{}" }, + ]) + const stream = + boundary === "partial" + ? new ReadableStream({ + start(controller) { + for (const line of lines.slice(0, -2)) controller.enqueue(new TextEncoder().encode(line)) + }, + }) + : undefined + const server = startScriptedLLMServer([ + { lines, stream }, + { lines: textStopResponse("continued from a complete tool invocation") }, + ]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const bus = yield* Bus.Service + const questions = yield* Question.Service + const session = yield* sessions.create({ title: "Interrupted input provenance" }) + const reached = yield* Deferred.make() + const unsubscribe = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { + const part = event.properties.part + if ( + part.sessionID === session.id && + part.type === "tool" && + part.callID === callID && + part.state.status === (boundary === "partial" ? "pending" : "running") + ) + Deferred.doneUnsafe(reached, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + const running = yield* prompt + .prompt({ + sessionID: session.id, + agent: boundary === "partial" ? "build" : "plan", + harness: "default", + parts: [{ type: "text", text: "Use the requested tool." }], + }) + .pipe(Effect.forkChild) + yield* Deferred.await(reached).pipe(Effect.timeout("10 seconds")) + if (boundary === "complete-empty") { + while ((yield* questions.list()).length === 0) yield* Effect.sleep("10 millis") + } + const beforeStop = yield* sessions.messages({ sessionID: session.id }) + const beforeTool = beforeStop + .flatMap((message) => message.parts) + .find((part) => part.type === "tool" && part.callID === callID) + expect(beforeTool?.type).toBe("tool") + if (beforeTool?.type !== "tool") throw new Error("missing streamed tool") + expect(beforeTool.state.status).toBe(boundary === "partial" ? "pending" : "running") + expect(beforeTool.state.input).toEqual({}) + yield* prompt.cancel(session.id) + yield* Fiber.join(running) + const stopped = yield* sessions.messages({ sessionID: session.id }) + const carrier = stopped.find((message) => + message.parts.some((part) => part.type === "tool" && part.callID === callID), + )! + const stoppedTool = carrier.parts.find((part) => part.type === "tool" && part.callID === callID)! + expect(stoppedTool.type === "tool" && stoppedTool.state.status).toBe("error") + const deleted: string[] = [] + const off = yield* bus.subscribeCallback(MessageV2.Event.Removed, (event) => { + if (event.properties.sessionID === session.id) deleted.push(event.properties.messageID) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + const resumed = yield* prompt + .resume({ sessionID: session.id, assistantMessageID: carrier.info.id }) + .pipe(Effect.exit) + expect(resumed._tag).toBe(boundary === "partial" ? "Failure" : "Success") + expect(stoppedTool).toMatchObject({ + state: { status: "error", input: {}, inputComplete: boundary === "complete-empty" }, + }) + const after = yield* sessions.messages({ sessionID: session.id }) + if (boundary === "partial") { + expect(server.captures).toHaveLength(1) + expect(after).toEqual(stopped) + expect(deleted).toEqual([]) + } else { + expect(server.captures).toHaveLength(2) + expect(server.captures[1].messages.at(-1)?.role).toBe("tool") + expect(after.find((message) => message.info.id === carrier.info.id)?.parts).toEqual(carrier.parts) + expect( + after.filter((message) => message.info.role === "user").map((message) => message.info.id), + ).toEqual(stopped.filter((message) => message.info.role === "user").map((message) => message.info.id)) + } + }), + { + git: true, + config: { + enabled_providers: ["test"], + model: "test/model", + provider: { + test: { + npm: "@ai-sdk/openai-compatible", + env: [], + options: { apiKey: "test-key", baseURL: `${server.origin}/v1` }, + models: { + model: { + name: "Test", + tool_call: true, + limit: { context: 32000, output: 2000 }, + modalities: { input: ["text"], output: ["text"] }, + }, + }, + }, + }, + agent: { build: { model: "test/model" }, plan: { model: "test/model" } }, + permission: { question: "allow" }, + lsp: false, + formatter: false, + }, + }, + ) + }), + 30000, + ) +} diff --git a/packages/sdk/src/v2/gen/sdk.gen.ts b/packages/sdk/src/v2/gen/sdk.gen.ts index 807b73fe29..bd8e9ae053 100644 --- a/packages/sdk/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/src/v2/gen/sdk.gen.ts @@ -2719,6 +2719,10 @@ export class Session2 extends HeyApiClient { modelID?: string userMessageID?: string assistantMessageID?: string + attempt?: { + requestId: string + attemptId: string + } }, options?: Options, ) { @@ -2737,6 +2741,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "modelID" }, { in: "body", key: "userMessageID" }, { in: "body", key: "assistantMessageID" }, + { in: "body", key: "attempt" }, ], }, ], diff --git a/packages/sdk/src/v2/gen/types.gen.ts b/packages/sdk/src/v2/gen/types.gen.ts index 0148a502af..9a9d401223 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -1245,6 +1245,7 @@ export type ToolStateError = { input: { [key: string]: unknown } + inputComplete?: boolean error: string metadata?: { [key: string]: unknown @@ -5592,6 +5593,10 @@ export type SessionResumeUserData = { body?: { userMessageID?: string assistantMessageID?: string + attempt?: { + requestId: string + attemptId: string + } } path: { sessionID: string From 522037aac91b8541895ae7aa17816b40372cc0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Sat, 10 Oct 2026 15:56:00 +0800 Subject: [PATCH 04/15] fix(effect): isolate shared initialization from turn cancellation --- packages/cli/src/effect/instance-state.ts | 28 ++- .../cli/test/effect/instance-state.test.ts | 200 +++++++++++++++++- .../cli/test/session/tool-gate-cancel.test.ts | 129 +++++++++++ 3 files changed, 350 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/effect/instance-state.ts b/packages/cli/src/effect/instance-state.ts index 9bb5a19a18..7790188088 100644 --- a/packages/cli/src/effect/instance-state.ts +++ b/packages/cli/src/effect/instance-state.ts @@ -1,4 +1,4 @@ -import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" +import { Deferred, Effect, Fiber, ScopedCache, Scope, Context } from "effect" import * as EffectLogger from "./logger" import { Instance, type InstanceContext } from "@/project/instance" import { LocalContext } from "@/util" @@ -10,7 +10,7 @@ const TypeId = "~opencode/InstanceState" export interface InstanceState { readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache + readonly cache: ScopedCache.ScopedCache, never, R> } export const bind = any>(fn: F): F => { @@ -41,12 +41,27 @@ export const make = ( opts?: { phase?: "normal" | "late" }, ): Effect.Effect>, never, R | Scope.Scope> => Effect.gen(function* () { - const cache = yield* ScopedCache.make({ + const cache = yield* ScopedCache.make, never, R>({ capacity: Number.POSITIVE_INFINITY, lookup: () => Effect.gen(function* () { - return yield* init(yield* context) - }), + const ctx = yield* context + const workspace = yield* workspaceID + const entry = yield* Effect.scope + // Register resources first so entry closure stops their users before releasing them. + const resources = yield* Scope.fork(entry) + const result = yield* Deferred.make() + yield* Effect.suspend(() => init(ctx)).pipe( + Scope.provide(resources), + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspace), + Effect.interruptible, + Deferred.into(result), + // Enter Deferred.into even if invalidation precedes the fiber's first scheduled step. + Effect.forkIn(entry, { uninterruptible: true }), + ) + return result + }).pipe(Effect.uninterruptible), }) const off = registerDisposer( @@ -63,7 +78,8 @@ export const make = ( export const get = (self: InstanceState) => Effect.gen(function* () { - return yield* ScopedCache.get(self.cache, yield* directory) + const result = yield* ScopedCache.get(self.cache, yield* directory).pipe(Effect.uninterruptible) + return yield* Deferred.await(result) }) export const use = (self: InstanceState, select: (value: A) => B) => Effect.map(get(self), select) diff --git a/packages/cli/test/effect/instance-state.test.ts b/packages/cli/test/effect/instance-state.test.ts index 9b4bab07bc..8fa1e65f3f 100644 --- a/packages/cli/test/effect/instance-state.test.ts +++ b/packages/cli/test/effect/instance-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test" -import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" +import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context, ScopedCache } from "effect" import { InstanceState } from "../../src/effect" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" @@ -10,6 +10,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID } from "../../src/session/schema" +import { disposeInstance } from "../../src/effect/instance-registry" import { ModelID, ProviderID } from "../../src/provider/schema" import { testEffect } from "../lib/effect" import "../../src/server/projectors" @@ -584,6 +585,203 @@ test("InstanceState dedupes concurrent lookups", async () => { ) }) +// [TP-RUN-R4-09] +it.live("InstanceState cancellation of the first caller preserves initialization for concurrent and later turns", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const waiting = yield* Deferred.make() + const events: string[] = [] + let attempts = 0 + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + const attempt = ++attempts + events.push(`acquire:${attempt}`) + yield* Effect.addFinalizer(() => Effect.sync(() => { events.push(`release:${attempt}`) })) + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + return { attempt } + }), + ) + const first = yield* InstanceState.get(state).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const concurrent = yield* Effect.gen(function* () { + yield* Deferred.succeed(waiting, undefined) + return yield* InstanceState.get(state) + }).pipe(Effect.forkChild) + yield* Deferred.await(waiting) + yield* Fiber.interrupt(first).pipe(Effect.timeout("1 second")) + expect(Exit.isFailure(yield* Fiber.await(first))).toBe(true) + expect(concurrent.pollUnsafe()).toBeUndefined() + expect(events).toEqual(["acquire:1"]) + yield* Deferred.succeed(release, undefined) + const value = yield* Fiber.join(concurrent) + expect(yield* InstanceState.get(state)).toBe(value) + expect(attempts).toBe(1) + yield* InstanceState.invalidate(state) + expect(events).toEqual(["acquire:1", "release:1"]) + }), + ), +) + +// [TP-RUN-R4-09] +for (const operation of ["invalidate", "dispose"] as const) { + it.live(`InstanceState ${operation} waits for initialization exit before releasing resources without touching a new entry`, () => + provideTmpdirInstance((directory) => + Effect.gen(function* () { + const entered = yield* Deferred.make() + const stopping = yield* Deferred.make() + const release = yield* Deferred.make() + const events: string[] = [] + let attempts = 0 + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + const attempt = ++attempts + events.push(`acquire:${attempt}`) + yield* Effect.addFinalizer(() => Effect.sync(() => { events.push(`release:${attempt}`) })) + if (attempt === 1) { + yield* Deferred.succeed(entered, undefined) + yield* Effect.never.pipe(Effect.ensuring(Effect.gen(function* () { + events.push("stopping:1") + yield* Deferred.succeed(stopping, undefined) + yield* Deferred.await(release) + events.push("stopped:1") + }))) + } + return { attempt } + }), + ) + const first = yield* InstanceState.get(state).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const closing = yield* (operation === "invalidate" + ? InstanceState.invalidate(state) + : Effect.promise(() => disposeInstance(directory))).pipe(Effect.forkChild) + yield* Deferred.await(stopping).pipe(Effect.timeout("1 second")) + expect(closing.pollUnsafe()).toBeUndefined() + expect(events).toEqual(["acquire:1", "stopping:1"]) + const next = yield* InstanceState.get(state) + expect(next).toEqual({ attempt: 2 }) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(closing) + expect(Exit.isFailure(yield* Fiber.await(first))).toBe(true) + expect(events).toEqual(["acquire:1", "stopping:1", "acquire:2", "stopped:1", "release:1"]) + expect(yield* InstanceState.get(state)).toBe(next) + yield* InstanceState.invalidate(state) + expect(events.at(-1)).toBe("release:2") + }), + ), + ) +} + +// [TP-RUN-R4-09] +it.live("InstanceState invalidation before initialization starts settles its result and permits a new entry", () => + provideTmpdirInstance((directory) => + Effect.gen(function* () { + let attempts = 0 + const state = yield* InstanceState.make(() => Effect.sync(() => ({ attempt: ++attempts }))) + const result = yield* ScopedCache.get(state.cache, directory) + expect(attempts).toBe(0) + yield* InstanceState.invalidate(state).pipe(Effect.timeout("1 second")) + const exit = yield* Deferred.await(result).pipe(Effect.exit, Effect.timeout("1 second")) + expect(Exit.isFailure(exit)).toBe(true) + expect(attempts).toBe(0) + const next = yield* InstanceState.get(state) + expect(next).toEqual({ attempt: 1 }) + expect(yield* InstanceState.get(state)).toBe(next) + }), + ), +) + +// [TP-RUN-R4-09] +it.live("InstanceState cancellation of a waiter preserves the shared initialization", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const waiting = yield* Deferred.make() + let attempts = 0 + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + attempts++ + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + return { ready: true } + }), + ) + const owner = yield* InstanceState.get(state).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const waiter = yield* Effect.gen(function* () { + yield* Deferred.succeed(waiting, undefined) + return yield* InstanceState.get(state) + }).pipe(Effect.forkChild) + yield* Deferred.await(waiting) + yield* Fiber.interrupt(waiter) + expect(Exit.isFailure(yield* Fiber.await(waiter))).toBe(true) + yield* Deferred.succeed(release, undefined) + const value = yield* Fiber.join(owner) + expect(yield* InstanceState.get(state)).toBe(value) + expect(attempts).toBe(1) + }), + ), +) + +// [TP-RUN-R4-09] +it.live("InstanceState finishes nested initialization without callers and reuses the completed value", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const finished = yield* Deferred.make() + const owner = yield* InstanceState.context + let innerAttempts = 0 + let outerAttempts = 0 + const inner = yield* InstanceState.make((ctx) => Effect.gen(function* () { + innerAttempts++ + yield* Effect.yieldNow + expect(yield* InstanceState.context).toBe(owner) + expect(ctx).toBe(owner) + return { ready: true } + })) + const outer = yield* InstanceState.make(() => Effect.gen(function* () { + outerAttempts++ + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + const value = yield* InstanceState.get(inner) + yield* Deferred.succeed(finished, undefined) + return value + })) + const first = yield* InstanceState.get(outer).pipe(Effect.forkChild) + yield* Deferred.await(entered) + yield* Fiber.interrupt(first).pipe(Effect.timeout("1 second")) + expect(Exit.isFailure(yield* Fiber.await(first))).toBe(true) + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(finished).pipe(Effect.timeout("1 second")) + expect(yield* InstanceState.get(outer)).toBe(yield* InstanceState.get(inner)) + expect({ innerAttempts, outerAttempts }).toEqual({ innerAttempts: 1, outerAttempts: 1 }) + }), + ), +) + +// [TP-RUN-R4-09] +it.live("InstanceState retains non-interruption failures until explicit invalidation", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + let attempts = 0 + const state = yield* InstanceState.make(() => + Effect.sync(() => { attempts++ }).pipe(Effect.andThen(Effect.fail("configuration invalid"))), + ) + const first = yield* InstanceState.get(state).pipe(Effect.exit) + const second = yield* InstanceState.get(state).pipe(Effect.exit) + expect(second).toEqual(first) + expect(attempts).toBe(1) + yield* InstanceState.invalidate(state) + yield* InstanceState.get(state).pipe(Effect.exit) + expect(attempts).toBe(2) + }), + ), +) + test("InstanceState survives deferred resume from the same instance context", async () => { await using tmp = await tmpdir({ git: true }) diff --git a/packages/cli/test/session/tool-gate-cancel.test.ts b/packages/cli/test/session/tool-gate-cancel.test.ts index 4020de655a..6085509274 100644 --- a/packages/cli/test/session/tool-gate-cancel.test.ts +++ b/packages/cli/test/session/tool-gate-cancel.test.ts @@ -7,6 +7,7 @@ import { Bus } from "../../src/bus" import { Question } from "../../src/question" import { MessageV2 } from "../../src/session/message-v2" import { ToolGate } from "../../src/tool/gate" +import * as Tool from "../../src/tool/tool" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -160,6 +161,134 @@ it.live( 20000, ) +// [TP-RUN-R4-09] [TP-CA-R8-09] +for (const recovery of ["send", "resume", "edit"] as const) { + it.live( + `Stop during cold ToolRegistry initialization allows ${recovery} in the same instance`, + () => + Effect.gen(function* () { + const server = startScriptedLLMServer([{ lines: textStopResponse("recovered request completed") }]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "Cold initialization cancellation" }) + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const original = Tool.init + const gated: typeof Tool.init = (info) => + info.id === "bash" + ? Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.suspend(() => original(info))), + ) + : original(info) + const initialize = spyOn(Tool, "init").mockImplementation(gated) + yield* Effect.addFinalizer(() => Effect.sync(() => initialize.mockRestore())) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + const first = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + harness: "default", + parts: [{ type: "text", text: "First cold request" }], + }) + .pipe(Effect.forkChild) + yield* Deferred.await(entered).pipe(Effect.timeout("10 seconds")) + yield* prompt.cancel(session.id) + yield* Fiber.join(first) + const stopped = yield* sessions.messages({ sessionID: session.id }) + const interrupted = stopped.find((message) => message.info.role === "assistant") + expect(interrupted?.info).toMatchObject({ + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }) + expect(server.captures).toHaveLength(0) + if (interrupted?.info.role !== "assistant") throw new Error("missing interrupted assistant") + const originalParentID = interrupted.info.parentID + const originalUser = stopped.find((message) => message.info.id === originalParentID)! + if (recovery === "edit") { + expect(stopped.map((message) => message.info.role)).toEqual(["user", "assistant"]) + for (const message of [...stopped].reverse()) { + yield* sessions.removeMessage({ sessionID: session.id, messageID: message.info.id }) + } + expect(yield* sessions.messages({ sessionID: session.id })).toEqual([]) + } + yield* Deferred.succeed(release, undefined) + const text = recovery === "edit" ? "Edited question after Stop" : "Fresh request after Stop" + const next = yield* recovery === "resume" + ? prompt.resume({ sessionID: session.id, assistantMessageID: interrupted.info.id }) + : prompt.prompt({ + sessionID: session.id, + agent: "build", + harness: "default", + parts: [{ type: "text", text }], + }) + const after = yield* sessions.messages({ sessionID: session.id }) + const users = after.filter((message) => message.info.role === "user") + expect(users).toHaveLength(recovery === "send" ? 2 : 1) + if (recovery === "edit") { + expect(after).toHaveLength(2) + expect(after.some((message) => stopped.some((old) => old.info.id === message.info.id))).toBe(false) + expect(users[0].parts).toEqual([expect.objectContaining({ type: "text", text })]) + expect(JSON.stringify(after)).not.toContain("First cold request") + } else { + const retainedUser = after.find((message) => message.info.id === originalUser.info.id)! + expect(retainedUser.info).toMatchObject(originalUser.info) + expect(retainedUser.parts).toEqual(originalUser.parts) + } + expect(next.info.id).not.toBe(interrupted.info.id) + expect(next.info).toMatchObject({ + role: "assistant", + parentID: users.at(-1)!.info.id, + time: { completed: expect.any(Number) }, + }) + expect(next.info.role === "assistant" ? next.info.error : undefined).toBeUndefined() + expect(server.captures).toHaveLength(1) + expect(server.captures[0].messages.at(-1)?.role).toBe("user") + expect(JSON.stringify(server.captures[0].messages)).toContain( + recovery === "resume" ? "First cold request" : text, + ) + if (recovery === "edit") { + expect(JSON.stringify(server.captures[0].messages)).not.toContain("First cold request") + expect(server.captures[0].messages.filter((message) => message.role === "user")).toHaveLength(1) + } + expect( + next.parts.some((part) => part.type === "text" && part.text === "recovered request completed"), + ).toBe(true) + }), + { + git: true, + config: { + enabled_providers: ["test"], + model: "test/model", + provider: { + test: { + npm: "@ai-sdk/openai-compatible", + env: [], + options: { apiKey: "test-key", baseURL: `${server.origin}/v1` }, + models: { + model: { + name: "Test", + tool_call: true, + limit: { context: 32000, output: 2000 }, + modalities: { input: ["text"], output: ["text"] }, + }, + }, + }, + }, + agent: { build: { model: "test/model" } }, + lsp: false, + formatter: false, + }, + }, + ) + }), + 20000, + ) +} + // [TP-SR-R21-28] [TP-SR-R21-30] for (const boundary of ["partial", "complete-empty"] as const) { it.live( From 31335ed169083c8decc9225499137471d7188138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 14:54:55 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix(tool):=20Stop=20=E6=97=B6=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=BC=82=E6=AD=A5=E5=B7=A5=E5=85=B7=E5=B9=B6=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E7=9C=9F=E5=AE=9E=E6=89=A7=E8=A1=8C=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settlement.ts:abort 后仍 consume 真实结果再抛中断 - processor executeTool/toolScope:Stop 关准入、等 Fiber 真退出 - 插件/脚本/沙箱工具接入可中断执行与结果登记 - tool-gate-cancel / processor-effect 覆盖结算、边界与冷启动取消 --- packages/cli/src/cli/cmd/import.ts | 10 +- packages/cli/src/plugin/index.ts | 49 +- packages/cli/src/session/message-v2.ts | 28 +- packages/cli/src/session/opencode-import.ts | 29 +- packages/cli/src/session/processor.ts | 235 +++++---- packages/cli/src/session/projectors.ts | 18 +- packages/cli/src/session/prompt.ts | 102 ++-- packages/cli/src/session/session.ts | 67 ++- packages/cli/src/sync/index.ts | 6 +- packages/cli/src/tool/registry.ts | 41 +- packages/cli/src/tool/settlement.ts | 33 ++ packages/cli/src/tool/tool-script.ts | 71 ++- packages/cli/src/workflow/sandbox.ts | 15 +- packages/cli/test/history/import.test.ts | 60 ++- packages/cli/test/session/message-v2.test.ts | 36 ++ .../cli/test/session/processor-effect.test.ts | 225 ++++++++ .../cli/test/session/tool-gate-cancel.test.ts | 484 +++++++++++++++++- packages/cli/test/tool/tool-script.test.ts | 19 + packages/sdk/src/gen/types.gen.ts | 1 + packages/sdk/src/v2/gen/types.gen.ts | 1 + 20 files changed, 1315 insertions(+), 215 deletions(-) create mode 100644 packages/cli/src/tool/settlement.ts diff --git a/packages/cli/src/cli/cmd/import.ts b/packages/cli/src/cli/cmd/import.ts index 22eccfd75c..2fedac520d 100644 --- a/packages/cli/src/cli/cmd/import.ts +++ b/packages/cli/src/cli/cmd/import.ts @@ -5,7 +5,7 @@ import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" -import { Database } from "../../storage" +import { Database, eq } from "../../storage" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { Instance } from "../../project/instance" import { ShareNext } from "../../share" @@ -95,7 +95,15 @@ export function storeImportedSession( const ids = [] for (const part of msg.parts) { const partInfo = MessageV2.Part.parse(part) + if (partInfo.type === "tool" && partInfo.execution === "not-started") delete partInfo.execution const { id: partId, sessionID: _s, messageID, ...partData } = partInfo + const current = tx.select().from(PartTable).where(eq(PartTable.id, partId)).get() + if (current && (current.data.type === "tool" || partInfo.type === "tool")) { + if (current.session_id !== row.id || current.message_id !== messageID || + current.data.type !== "tool" || partInfo.type !== "tool") throw new Error("Imported tool identity mismatch") + const previous = MessageV2.ToolPart.parse({ ...current.data, id: current.id, sessionID: current.session_id, messageID: current.message_id }) + if (previous.callID !== partInfo.callID) throw new Error("Imported tool call identity mismatch") + } tx.insert(PartTable) .values({ id: partId, message_id: messageID, session_id: row.id, data: partData }) .onConflictDoNothing() diff --git a/packages/cli/src/plugin/index.ts b/packages/cli/src/plugin/index.ts index bcb745608d..bed3f9390d 100644 --- a/packages/cli/src/plugin/index.ts +++ b/packages/cli/src/plugin/index.ts @@ -30,8 +30,9 @@ import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" -import { Effect, Layer, Context, Stream } from "effect" +import { Effect, Exit, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect" +import * as ToolSettlement from "@/tool/settlement" import { InstanceState } from "@/effect" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" @@ -631,11 +632,16 @@ export const layer = Layer.effect( if (!name) return output const s = yield* InstanceState.get(state) const fh = yield* freshFileHooks + const toolHook = name === "tool.execute.before" || name === "tool.execute.after" for (const entry of s.hooksWithMeta ?? []) { const fn = entry?.hook?.[name] as ((input: Input, output: Output) => unknown) | undefined if (typeof fn !== "function") continue - yield* Effect.promise(async () => fn(input, output)) + yield* toolHook + ? ToolSettlement.promise(async () => fn(input, output), (exit) => + Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), + ).pipe(Effect.orDie) + : Effect.promise(async () => fn(input, output)) } for (const entry of fh.meta ?? []) { @@ -649,17 +655,7 @@ export const layer = Layer.effect( } const snapshot = structuredClone(output) - const failed = yield* Effect.tryPromise({ - try: async () => { - await Promise.race([ - Promise.resolve(fn(input, output)), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), - ), - ]) - }, - catch: (err) => err, - }).pipe( + const finish = (effect: Effect.Effect) => effect.pipe( Effect.map(() => false), Effect.catch((err) => { Object.assign(output as any, snapshot) @@ -675,6 +671,33 @@ export const layer = Layer.effect( return Effect.succeed(true) }), ) + const failed = yield* toolHook + ? ToolSettlement.promise(() => { + const pending = Promise.resolve().then(() => fn(input, output)) + let timer: ReturnType + const timed = Promise.race([ + pending, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS) + }), + ]) + return timed.catch((error) => pending.then( + () => { throw error }, + () => { throw error }, + )).finally(() => clearTimeout(timer)) + }, (exit) => finish(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause))) + .pipe(Effect.orDie) + : finish(Effect.tryPromise({ + try: async () => { + await Promise.race([ + Promise.resolve(fn(input, output)), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), + ), + ]) + }, + catch: (err) => err, + })) if (!failed) hookFailures.delete(hookID) } return output diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 96772aa150..1a9ace0852 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -484,6 +484,7 @@ export const ToolState = z export const ToolPart = PartBase.extend({ type: z.literal("tool"), + execution: z.enum(["not-started", "started"]).optional(), callID: z.string(), tool: z.string(), state: ToolState, @@ -851,13 +852,29 @@ function providerMeta(metadata: Record | undefined) { export function planResumeTrim(input: WithParts[], parentID: MessageID) { const parent = input.findIndex((message) => message.info.id === parentID && message.info.role === "user") if (parent < 0) throw new Error("Resume parent is missing") - const tail = input.slice(parent + 1) - if (tail.some((message) => message.info.role !== "assistant" || message.info.parentID !== parentID)) { + const sourceTail = input.slice(parent + 1) + if (sourceTail.some((message) => message.info.role !== "assistant" || message.info.parentID !== parentID)) { throw new Error("Resume trim crosses a parent boundary") } - const lastToolMessage = tail.findLastIndex((message) => message.parts.some((part) => part.type === "tool")) const removeMessages: MessageID[] = [] const removeParts: Part[] = [] + const tail = sourceTail.map((message) => ({ + ...message, + parts: message.parts.filter((part) => { + if (part.type !== "tool" || part.execution !== "not-started" || part.metadata?.providerExecuted || + part.state.status === "completed" || ("attachments" in part.state && part.state.attachments?.length) || + ("output" in part.state && part.state.output !== undefined) || + ("metadata" in part.state && (part.state.metadata?.output !== undefined || part.state.metadata?.diff !== undefined))) return true + if (message.info.role === "assistant" && (message.info.summary || message.info.structured !== undefined || + (!message.info.error && message.info.finish && message.info.finish !== "length" && message.info.finish !== "tool-calls" && + (message.info.finish === "stop" || message.info.time.completed !== undefined)))) { + throw new Error("Resume trim would discard protected output") + } + removeParts.push(part) + return false + }), + })) + const lastToolMessage = tail.findLastIndex((message) => message.parts.some((part) => part.type === "tool")) const updateParts: ToolPart[] = [] const updateMessages: Assistant[] = [] const retained = tail.flatMap((message, index): WithParts[] => { @@ -883,9 +900,12 @@ export function planResumeTrim(input: WithParts[], parentID: MessageID) { : part.type === "reasoning" && part.text.trim().length > 0)) || discarded.some((part) => !["text", "reasoning", "step-start", "step-finish", "retry"].includes(part.type)) )) throw new Error("Resume trim would discard protected output") - if (cut === 0 && !hasContent) return [message] + if (cut === 0 && !hasContent && !removeParts.some((part) => part.messageID === info.id)) return [message] if (cut === 0) { removeMessages.push(info.id) + for (let i = removeParts.length - 1; i >= 0; i--) { + if (removeParts[i].messageID === info.id) removeParts.splice(i, 1) + } return [] } removeParts.push(...discarded) diff --git a/packages/cli/src/session/opencode-import.ts b/packages/cli/src/session/opencode-import.ts index a665ab1d22..fe95167792 100644 --- a/packages/cli/src/session/opencode-import.ts +++ b/packages/cli/src/session/opencode-import.ts @@ -1,4 +1,5 @@ import { indexImportedParts } from "../history/import" +import { MessageV2 } from "./message-v2" import { existsSync } from "fs" import { Log } from "../util" import { Database, eq, and, inArray } from "../storage" @@ -159,6 +160,30 @@ export async function run(opts?: { force?: boolean; dbPath?: string }): Promise< } Database.transaction((tx) => { + const previous = new Map(tx.select().from(PartTable).where(eq(PartTable.session_id, sess.id as SessionID)).all().map((row) => [row.id, row])) + const importedParts = parts.map((p) => { + const data = JSON.parse(p.data) + const current = previous.get(p.id as PartID) ?? tx.select().from(PartTable).where(eq(PartTable.id, p.id as PartID)).get() + if (data.type === "tool" && data.execution === "not-started") delete data.execution + if (current && (data.type === "tool" || current.data.type === "tool")) { + if (current.session_id !== sess.id || current.message_id !== p.message_id || + data.type !== "tool" || current.data.type !== "tool") throw new Error("Imported tool identity mismatch") + const tool = MessageV2.ToolPart.parse({ ...current.data, id: current.id, sessionID: current.session_id, messageID: current.message_id }) + if (tool.callID !== data.callID) throw new Error("Imported tool call identity mismatch") + if (tool.execution === "started") data.execution = "started" + if (tool.metadata?.providerExecuted) data.metadata = { ...data.metadata, providerExecuted: true } + if (tool.state.status === "completed" || tool.state.status === "error") data.state = tool.state + } + return { ...p, data } + }) + const importedIDs = new Set(parts.map((part) => part.id)) + const replacedMessages = existing?.message_ids?.length ? new Set(existing.message_ids) : undefined + for (const row of previous.values()) { + if (!existing || (replacedMessages && !replacedMessages.has(row.message_id)) || + row.data.type !== "tool" || importedIDs.has(row.id)) continue + const tool = MessageV2.ToolPart.parse({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) + if (tool.execution === "started" || tool.metadata?.providerExecuted) throw new Error("Import would remove executed tool") + } tx.insert(ProjectTable) .values({ id: project.id, @@ -232,8 +257,8 @@ export async function run(opts?: { force?: boolean; dbPath?: string }): Promise< .run() } - for (const p of parts) { - const data = typeof p.data === "string" ? JSON.parse(p.data) : p.data + for (const p of importedParts) { + const data = p.data tx.insert(PartTable) .values({ id: p.id as PartID, diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 141262d4a6..a8f58a2ec5 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent, resolveStoredAgent } from "@/agent/agent" import { SYSTEM_SPAWNED_AGENT_TYPES } from "@/agent/config" @@ -22,6 +22,7 @@ import type { Provider } from "@/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" import { ToolGate, FAIL_CASCADE_MESSAGE } from "@/tool/gate" +import { ResultRef } from "@/tool/settlement" import { isRecoverableError } from "@/tool/recoverable" import { getToolResultAttachments, getToolResultMetadata } from "@/tool/result-error" import { Log } from "@/util" @@ -158,6 +159,13 @@ export type ReplayInput = { export interface Handle { readonly toolGate: ToolGate + readonly registerTool: (name: string) => void + readonly executeTool: ( + tool: string, + callID: string, + body: Effect.Effect, + options?: { signal?: AbortSignal; gateTool?: string }, + ) => Effect.Effect readonly message: MessageV2.Assistant readonly updateToolCall: ( toolCallID: string, @@ -201,7 +209,6 @@ type ToolCall = { partID: MessageV2.ToolPart["id"] messageID: MessageV2.ToolPart["messageID"] sessionID: MessageV2.ToolPart["sessionID"] - done: Deferred.Deferred } interface ProcessorContext extends Input { @@ -259,6 +266,22 @@ export const layer: Layer.Layer< // so capturing inside the event handler can be too late. const initialSnapshot = yield* snapshot.track() const toolGate = new ToolGate() + let toolScope = yield* Scope.make() + let toolsClosed = false + const closeTools = () => Effect.gen(function* () { + toolsClosed = true + yield* Scope.close(toolScope, Exit.void) + }) + const localTools = new Set() + const ready = new Map>() + const toolReady = (callID: string) => { + let value = ready.get(callID) + if (!value) { + value = Deferred.makeUnsafe() + ready.set(callID, value) + } + return value + } const ctx: ProcessorContext = { assistantMessage: input.assistantMessage, sessionID: input.sessionID, @@ -344,41 +367,13 @@ export const layer: Layer.Layer< .pipe(Effect.ignore) }) - const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { - const done = ctx.toolcalls[toolCallID]?.done - delete ctx.toolcalls[toolCallID] - if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore) - }) - - const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { - const call = ctx.toolcalls[toolCallID] - if (!call) return - const part = yield* session.getPart({ - partID: call.partID, - messageID: call.messageID, - sessionID: call.sessionID, - }) - if (!part || part.type !== "tool") { - delete ctx.toolcalls[toolCallID] - return - } - return { call, part } - }) - const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( toolCallID: string, update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, ) { - const match = yield* readToolCall(toolCallID) - if (!match) return - const part = yield* session.updatePart(update(match.part)) - ctx.toolcalls[toolCallID] = { - ...match.call, - partID: part.id, - messageID: part.messageID, - sessionID: part.sessionID, - } - return part + const call = ctx.toolcalls[toolCallID] + if (!call) return + return yield* session.mutateToolPart(call, (part) => part ? update(part) : undefined) }) const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* ( @@ -386,37 +381,38 @@ export const layer: Layer.Layer< output: unknown, providerMetadata?: Record, ) { - const match = yield* readToolCall(toolCallID) - if (!match || match.part.state.status !== "running") return const result = isToolExecutionResult(output) ? output : undefined - const structured = !result && match.part.metadata?.providerExecuted - const part = yield* session.updatePart({ - ...match.part, - state: { - status: "completed", - input: match.part.state.input, - output: result?.output ?? displayToolOutput(output), - ...(structured ? { providerOutput: jsonToolOutput(output) } : {}), - ...(providerMetadata ? { providerMetadata } : {}), - metadata: result?.metadata ?? {}, - title: result?.title ?? "", - time: { start: match.part.state.time.start, end: Date.now() }, - attachments: result?.attachments, - }, + let changed = false + const part = yield* updateToolCall(toolCallID, (current) => { + if (current.state.status !== "running") return current + changed = true + const structured = !result && current.metadata?.providerExecuted + return { + ...current, + state: { + status: "completed", + input: current.state.input, + output: result?.output ?? displayToolOutput(output), + ...(structured ? { providerOutput: jsonToolOutput(output) } : {}), + ...(providerMetadata ? { providerMetadata } : {}), + metadata: result?.metadata ?? {}, + title: result?.title ?? "", + time: { start: current.state.time.start, end: Date.now() }, + attachments: result?.attachments, + }, + } }) + if (!part || !changed) return yield* detectTryBest(part) - yield* settleToolCall(toolCallID) }) const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { - const match = yield* readToolCall(toolCallID) - if (!match || match.part.state.status !== "running") return false + let changed = false // Agent-recoverable failures (bad args, malformed call, unknown task/actor // id) carry a marker the TUI reads to render them muted instead of as a red // error block. The full actionable message still flows to the model. const recoverable = isRecoverableError(error) const metadata = { - ...match.part.state.metadata, ...getToolResultMetadata(error), ...(recoverable ? { recoverable: true } : {}), } @@ -424,26 +420,66 @@ export const layer: Layer.Layer< const parsed = MessageV2.FilePart.safeParse(attachment) return parsed.success ? [parsed.data] : [] }) - const part = yield* session.updatePart({ - ...match.part, - state: { - status: "error", - input: match.part.state.input, - inputComplete: true, - error: errorMessage(error), - ...(Object.keys(metadata).length > 0 ? { metadata } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - time: { start: match.part.state.time.start, end: Date.now() }, - }, + const part = yield* updateToolCall(toolCallID, (current) => { + if (current.state.status !== "running") return current + changed = true + return { + ...current, + state: { + status: "error", + input: current.state.input, + inputComplete: true, + error: errorMessage(error), + metadata: { ...current.state.metadata, ...metadata }, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + time: { start: current.state.time.start, end: Date.now() }, + }, + } }) + if (!part || !changed) return false yield* detectTryBest(part) if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) { ctx.blocked = ctx.blocked || ctx.shouldBreak } - yield* settleToolCall(toolCallID) return true }) + const executeTool: Handle["executeTool"] = (tool, callID, body, options) => Effect.gen(function* () { + if (toolsClosed || options?.signal?.aborted) return yield* Effect.fail(new Error("Tool execution is closed")) + const execution = Effect.gen(function* () { + yield* Deferred.await(toolReady(callID)) + return yield* toolGate.run(options?.gateTool ?? tool, callID, Effect.gen(function* () { + const call = ctx.toolcalls[callID] + if (!call) return yield* Effect.fail(new Error("Tool call is not registered")) + yield* session.mutateToolPart(call, (part) => { + if (toolsClosed || options?.signal?.aborted) throw new Error("Tool execution is closed") + if (!part || part.tool !== tool || part.callID !== callID || part.state.status !== "running" || + part.execution !== "not-started" || part.metadata?.providerExecuted) { + throw new Error("Tool execution has no trusted input boundary") + } + return { ...part, execution: "started" } + }) + const settled: { callID: string; result?: Effect.Effect } = { callID } + return yield* Effect.uninterruptibleMask((restore) => restore(body.pipe( + Effect.provideService(ResultRef, settled), + )).pipe( + Effect.tap((output) => completeToolCall(callID, output)), + Effect.onExit((exit) => Exit.isFailure(exit) && settled.result + ? settled.result.pipe(Effect.matchEffect({ + onSuccess: (output) => completeToolCall(callID, output), + onFailure: (error) => failToolCall(callID, error).pipe(Effect.asVoid), + })) + : Effect.void), + Effect.tapError((error) => failToolCall(callID, error)), + )) + }), { signal: options?.signal }) + }) + const fiber = yield* execution.pipe(Effect.forkIn(toolScope)) + // Unwrap Exit after notification; join can skip concurrent scope waiters on interruption in Effect beta.48. + const exit = yield* Fiber.await(fiber) + return yield* exit + }) + const checkTextNgram = (text: string) => { if (ctx.textNgramRepeat || !ctx.textNgramMonitor) return if (ctx.textNgramMonitor.append(text)) ctx.textNgramRepeat = true @@ -502,19 +538,27 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - const part = yield* session.updatePart({ - id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), + if (toolsClosed) return + const partID = ctx.toolcalls[value.id]?.partID ?? PartID.ascending() + const part = yield* session.mutateToolPart({ + partID, messageID: ctx.assistantMessage.id, sessionID: ctx.sessionID, + }, (current) => current ? { + ...current, + metadata: value.providerExecuted ? { ...current.metadata, providerExecuted: true } : current.metadata, + } : { + id: partID, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "tool", tool: value.toolName, callID: value.id, + execution: !value.providerExecuted && localTools.has(value.toolName) ? "not-started" : undefined, state: { status: "pending", input: {}, raw: "" }, metadata: value.providerExecuted ? { providerExecuted: true } : undefined, - } satisfies MessageV2.ToolPart) + }) + if (!part) throw new Error("Tool registration was not persisted") ctx.stepPartIds.push(part.id) ctx.toolcalls[value.id] = { - done: yield* Deferred.make(), partID: part.id, messageID: part.messageID, sessionID: part.sessionID, @@ -537,16 +581,18 @@ export const layer: Layer.Layer< yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, - state: { - ...match.state, + execution: match.execution ?? (match.state.status === "pending" && localTools.has(value.toolName) && + !match.metadata?.providerExecuted && !value.providerExecuted ? "not-started" : undefined), + state: match.state.status !== "pending" ? match.state : { status: "running", input: value.input, time: { start: Date.now() }, }, - metadata: match.metadata?.providerExecuted + metadata: match.metadata?.providerExecuted || value.providerExecuted ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, })) + yield* Deferred.succeed(toolReady(value.toolCallId), undefined) const parts = MessageV2.parts(ctx.assistantMessage.id) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) @@ -579,11 +625,17 @@ export const layer: Layer.Layer< } case "tool-result": { + if (value.providerExecuted) yield* updateToolCall(value.toolCallId, (part) => ({ + ...part, metadata: { ...part.metadata, providerExecuted: true }, + })) yield* completeToolCall(value.toolCallId, value.output, value.providerMetadata) return } case "tool-error": { + if (value.providerExecuted) yield* updateToolCall(value.toolCallId, (part) => ({ + ...part, metadata: { ...part.metadata, providerExecuted: true }, + })) yield* failToolCall(value.toolCallId, value.error) yield* bus .publish(Metrics.ToolCall, { @@ -755,6 +807,7 @@ export const layer: Layer.Layer< }) const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () { + yield* closeTools() if (ctx.snapshot) { const patch = yield* snapshot.patch(ctx.snapshot) if (patch.files.length) { @@ -788,21 +841,12 @@ export const layer: Layer.Layer< } ctx.reasoningMap = {} - yield* Effect.forEach( - Object.values(ctx.toolcalls), - (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore), - { concurrency: "unbounded" }, - ) - for (const toolCallID of Object.keys(ctx.toolcalls)) { - const match = yield* readToolCall(toolCallID) - if (!match) continue - yield* session.updatePart({ - ...match.part, - state: MessageV2.abortedToolState( - match.part.state, - toolGate.wasCancelled(toolCallID) ? FAIL_CASCADE_MESSAGE : undefined, - ), + yield* updateToolCall(toolCallID, (part) => { + if (part.state.status !== "pending" && part.state.status !== "running") return part + return { ...part, state: MessageV2.abortedToolState( + part.state, toolGate.wasCancelled(toolCallID) ? FAIL_CASCADE_MESSAGE : undefined, + ) } }) } ctx.toolcalls = {} @@ -817,12 +861,11 @@ export const layer: Layer.Layer< for (const part of yield* Effect.sync(() => MessageV2.parts(ctx.assistantMessage.id))) { if (part.type !== "tool") continue if (part.state.status !== "pending" && part.state.status !== "running") continue - yield* session.updatePart({ - ...part, - state: MessageV2.abortedToolState( - part.state, - toolGate.wasCancelled(part.callID) ? FAIL_CASCADE_MESSAGE : undefined, - ), + yield* session.mutateToolPart({ sessionID: part.sessionID, messageID: part.messageID, partID: part.id }, (current) => { + if (!current || (current.state.status !== "pending" && current.state.status !== "running")) return current + return { ...current, state: MessageV2.abortedToolState( + current.state, toolGate.wasCancelled(current.callID) ? FAIL_CASCADE_MESSAGE : undefined, + ) } }) } // 有 error = 没完成 = 留在 /recovery 候选集里。不管错误类型(瞬态/终态/用户中止), @@ -868,6 +911,11 @@ export const layer: Layer.Layer< ctx.reasoningMap = {} ctx.stepPartIds = [] ctx.toolcalls = {} + if (toolsClosed) { + toolScope = yield* Scope.make() + toolsClosed = false + ready.clear() + } ctx.retrySafe = true ctx.textNgramRepeat = false ctx.textNgramMonitor = createTextNgramMonitor() @@ -877,6 +925,7 @@ export const layer: Layer.Layer< Stream.tap(handleEvent), Stream.takeUntil(() => ctx.needsOverflowHandling || ctx.textNgramRepeat || ctx.blocked), Stream.runDrain, + Effect.ensuring(closeTools()), ) }).pipe( Effect.onInterrupt(() => @@ -1236,6 +1285,8 @@ export const layer: Layer.Layer< return { toolGate, + registerTool: (name) => { localTools.add(name) }, + executeTool, get message() { return ctx.assistantMessage }, diff --git a/packages/cli/src/session/projectors.ts b/packages/cli/src/session/projectors.ts index a847e4200c..570c963c00 100644 --- a/packages/cli/src/session/projectors.ts +++ b/packages/cli/src/session/projectors.ts @@ -143,8 +143,24 @@ export default [ .run() }), - SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => { + SyncEvent.project(MessageV2.Event.PartUpdated, (db, data, source) => { const { id, messageID, sessionID, ...rest } = data.part + const current = db.select().from(PartTable).where(eq(PartTable.id, id)).get() + if (current?.data.type === "tool" && rest.type !== "tool") throw new Error("Tool part type changed") + if (rest.type === "tool") { + if (current && (current.session_id !== sessionID || current.message_id !== messageID || + current.data.type !== "tool")) throw new Error("Tool part identity mismatch") + if (source.replay && rest.execution === "not-started") rest.execution = undefined + if (current) { + const tool = MessageV2.ToolPart.parse({ ...current.data, id: current.id, sessionID: current.session_id, messageID: current.message_id }) + if (tool.callID !== rest.callID) throw new Error("Tool call identity mismatch") + if (tool.execution === "started") rest.execution = "started" + if ((tool.state.status === "completed" || tool.state.status === "error") && + (source.replay || rest.state.status !== tool.state.status)) rest.state = tool.state + if (tool.metadata?.providerExecuted) rest.metadata = { ...rest.metadata, providerExecuted: true } + } + data.part = { ...rest, id, messageID, sessionID } + } try { db.insert(PartTable) diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 16d3adfebe..d785f100bc 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -149,6 +149,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Scope, Context } f import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import { PARALLEL_READONLY_TOOLS } from "@/tool/gate" +import * as ToolSettlement from "@/tool/settlement" import { ActorTool, type ActorPromptOps } from "@/tool/actor" import { SessionRunState } from "./run-state" import { ResumeTestHooks } from "./resume-test-hooks" @@ -1703,7 +1704,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: Provider.Model session: Session.Info tools?: Record - processor: Pick + processor: Pick bypassAgentCheck: boolean messages: MessageV2.WithParts[] agentID?: string @@ -1856,6 +1857,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the harness: input.harness, })) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) + input.processor.registerTool(item.id) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), @@ -1932,6 +1934,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: input.processor.message.id, })), } + yield* ToolSettlement.record(ctx.callID, Effect.succeed(output)) yield* plugin.trigger( "tool.execute.after", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args: beforeOutput.args }, @@ -1966,11 +1969,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the } return output }).pipe((body) => - gate.run( - gateTool, - options?.toolCallId ?? "?", + input.processor.executeTool( + item.id, + options.toolCallId, body, - { signal: options.abortSignal }, + { signal: options.abortSignal, gateTool }, ), ), ) @@ -2081,9 +2084,39 @@ NOTE: At any point in time through this workflow you should feel free to ask the return cancelResult } yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result: Awaited>> = yield* Effect.tryPromise({ - try: () => execute(mcpBeforeOutput.args, { + const normalizeResult = (result: Awaited>>) => Effect.gen(function* () { + const normalized = normalizeToolResult(result) + const truncated = yield* truncate.output( + normalized.output, + { outcome: normalized.isError ? "error" : "success" }, + input.agent, + ) + const metadata = { + ...normalized.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + } + const attachments = normalized.attachments.map((attachment) => ({ + type: "file" as const, + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })) + if (normalized.isError) { + return yield* Effect.fail(new ToolResultError( + truncated.content.trim() || "MCP tool execution failed", metadata, attachments, + )) + } + return { + normalized, + output: { title: "", metadata, output: truncated.content, attachments }, + } + }) + const result = yield* ToolSettlement.promise( + (signal) => Promise.resolve(execute(mcpBeforeOutput.args, { ...opts, + abortSignal: opts.abortSignal ? AbortSignal.any([opts.abortSignal, signal]) : signal, experimental_context: { onMcpToolProgress: (meta: Record) => run.promise( input.processor.updateToolCall(opts.toolCallId, (part) => { @@ -2094,9 +2127,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ), }, + })), + (exit) => Effect.gen(function* () { + const result = Exit.isSuccess(exit) ? exit.value : yield* Effect.failCause(exit.cause) + yield* ToolSettlement.record(opts.toolCallId, normalizeResult(result).pipe(Effect.map(({ output }) => output))) + return result }), - catch: (error) => error, - }).pipe(Effect.catch((error) => Effect.gen(function* () { + ).pipe(Effect.catch((error) => Effect.gen(function* () { // Catch SDK execution failures here, before direct/exec paths persist // or forward them. Successful results (including diffs) never enter. if (opts.abortSignal?.aborted) return yield* Effect.fail(error) @@ -2113,7 +2150,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the result, ) - const normalized = normalizeToolResult(result) + const { normalized, output } = yield* normalizeResult(result) log.debug("tool execute done (mcp)", { tool: key, callID, @@ -2121,34 +2158,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the ok: !normalized.isError, }) - const truncated = yield* truncate.output( - neutralizeHarnessTags(normalized.output), - { outcome: normalized.isError ? "error" : "success" }, - input.agent, - ) - const metadata = { - ...normalized.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - } - const attachments = normalized.attachments.map((attachment) => ({ - type: "file" as const, - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })) - - if (normalized.isError) { - return yield* Effect.fail( - new ToolResultError( - truncated.content.trim() || "MCP tool execution failed", - metadata, - attachments, - ), - ) - } - yield* bus .publish(Metrics.ToolCall, { sessionID: ctx.sessionID, @@ -2163,12 +2172,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) .pipe(Effect.ignore) - const output = { - title: "", - metadata, - output: truncated.content, - attachments, - } if (opts.abortSignal?.aborted) { yield* input.processor.completeToolCall(opts.toolCallId, output) } @@ -2176,10 +2179,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the }).pipe((body) => // Only model-facing calls join this batch. Exec guest calls keep // the same execution pipeline with script-controlled concurrency. - modelFacing ? gate.run(key, opts?.toolCallId ?? "?", body, { signal: opts.abortSignal }) : body, + modelFacing ? input.processor.executeTool(key, opts.toolCallId, body, { signal: opts.abortSignal }) : body, ), ) } + input.processor.registerTool(key) item.execute = (args, opts) => executeMcp(args, opts, true) tools[key] = item if (searchable && input.model.capabilities.toolcall) { @@ -3370,7 +3374,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the const started = part.state.status === "running" ? part.state.time.start : undefined if (opts?.before !== undefined && started !== undefined && started > opts.before) continue yield* sessions - .updatePart({ ...part, state: MessageV2.abortedToolState(part.state) }) + .mutateToolPart({ sessionID: part.sessionID, messageID: part.messageID, partID: part.id }, (current) => { + if (!current || (current.state.status !== "pending" && current.state.status !== "running")) return current + return { ...current, state: MessageV2.abortedToolState(current.state) } + }) .pipe( Effect.catchCause((cause) => elog.warn("orphan-tool-part-update-failed", { sessionID, partID: part.id, cause }), @@ -4986,11 +4993,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, }) const run = yield* runner() + handle.registerTool("StructuredOutput") tools["StructuredOutput"] = { ...outputTool, execute(args, options) { return run.promise( - handle.toolGate.run( + handle.executeTool( "StructuredOutput", options.toolCallId, Effect.promise(async () => outputTool.execute!(args, options)), diff --git a/packages/cli/src/session/session.ts b/packages/cli/src/session/session.ts index 210c230a85..621afbb7ab 100644 --- a/packages/cli/src/session/session.ts +++ b/packages/cli/src/session/session.ts @@ -18,6 +18,7 @@ import { Storage } from "@/storage" import { Log } from "../util" import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" +import { SessionRunState } from "./run-state" import { Instance } from "../project/instance" import { InstanceState } from "@/effect" import { Snapshot } from "@/snapshot" @@ -508,6 +509,10 @@ export interface Interface { partID: PartID }) => Effect.Effect readonly updatePart: (part: T) => Effect.Effect + readonly mutateToolPart: ( + input: { sessionID: SessionID; messageID: MessageID; partID: PartID }, + update: (part: MessageV2.ToolPart | undefined) => MessageV2.ToolPart | undefined, + ) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID messageID: MessageID @@ -534,12 +539,13 @@ type Patch = Omit["info"], "title" | "title const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service const actorReg = yield* ActorRegistry.Service + const runs = yield* SessionRunState.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -697,8 +703,41 @@ export const layer: Layer.Layer Effect.sync(() => + Database.transaction((db) => { + const row = db.select().from(PartTable).where(eq(PartTable.id, input.partID)).get() + if (row && (row.session_id !== input.sessionID || row.message_id !== input.messageID || row.data.type !== "tool")) { + throw new Error("Tool part identity mismatch") + } + const current = row ? MessageV2.ToolPart.parse({ ...row.data, id: row.id, messageID: row.message_id, sessionID: row.session_id }) : undefined + const next = update(current) + if (!next) return + if (next.id !== input.partID || next.sessionID !== input.sessionID || next.messageID !== input.messageID || + (current && current.callID !== next.callID)) throw new Error("Tool part identity changed") + if (current?.execution === "started") next.execution = "started" + if (current?.metadata?.providerExecuted) next.metadata = { ...next.metadata, providerExecuted: true } + if (current && (current.state.status === "completed" || current.state.status === "error") && + next.state.status !== current.state.status) next.state = current.state + SyncEvent.run(MessageV2.Event.PartUpdated, { sessionID: input.sessionID, part: structuredClone(next), time: Date.now() }) + const committed = db.select().from(PartTable).where(eq(PartTable.id, input.partID)).get() + if (!committed || committed.session_id !== input.sessionID || committed.message_id !== input.messageID) { + throw new Error("Tool part was not persisted") + } + const saved = MessageV2.ToolPart.parse({ ...committed.data, id: committed.id, messageID: committed.message_id, sessionID: committed.session_id }) + if (saved.execution !== next.execution || saved.callID !== next.callID) throw new Error("Tool execution boundary was not persisted") + return saved + }, { behavior: "immediate" }), + ) + const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { + if (part.type === "tool") { + const saved = yield* mutateToolPart({ sessionID: part.sessionID, messageID: part.messageID, partID: part.id }, (current) => ({ + ...part, + execution: current?.execution === "started" ? "started" : undefined, + })) + return saved as T + } yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.PartUpdated, { sessionID: part.sessionID, @@ -785,13 +824,25 @@ export const layer: Layer.Layer false, onSuccess: () => true }), + ) for (const part of msg.parts) { - yield* updatePart({ - ...part, - id: PartID.ascending(), - messageID: cloned.id, - sessionID: session.id, - }) + const copy = { ...part, id: PartID.ascending(), messageID: cloned.id, sessionID: session.id } + if (copy.type === "tool") { + yield* mutateToolPart({ partID: copy.id, messageID: copy.messageID, sessionID: copy.sessionID }, () => { + const source = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.id, part.id)).get()) + if (!source) return + const latest = MessageV2.ToolPart.parse({ ...source.data, id: source.id, sessionID: source.session_id, messageID: source.message_id }) + if (latest.sessionID !== part.sessionID || latest.messageID !== part.messageID || latest.callID !== copy.callID) { + throw new Error("Fork source tool identity changed") + } + return { ...latest, id: copy.id, sessionID: copy.sessionID, messageID: copy.messageID, + execution: latest.execution === "started" || idle ? latest.execution : undefined } + }) + } else { + yield* updatePart(copy) + } } } return session @@ -991,6 +1042,7 @@ export const layer: Layer.Layer = { export type SerializedEvent = Event & { type: string } -type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ProjectorFunc = (db: Database.TxOrDb, data: unknown, source: { replay: boolean }) => void export const registry = new Map() let projectors: Map | undefined @@ -96,7 +96,7 @@ export function define< export function project( def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, + func: (db: Database.TxOrDb, data: Event["data"], source: { replay: boolean }) => void, ): [Definition, ProjectorFunc] { return [def, func as ProjectorFunc] } @@ -114,7 +114,7 @@ function process(def: Def, event: Event, options: { // idempotent: need to ignore any events already logged Database.transaction((tx) => { - projector(tx, event.data) + projector(tx, event.data, { replay: options.replay === true }) if (Flag.MIMOCODE_EXPERIMENTAL_WORKSPACES || options.replay) { tx.insert(EventSequenceTable) diff --git a/packages/cli/src/tool/registry.ts b/packages/cli/src/tool/registry.ts index a56371305a..eb06e83c13 100644 --- a/packages/cli/src/tool/registry.ts +++ b/packages/cli/src/tool/registry.ts @@ -22,6 +22,7 @@ import { SkillTool } from "./skill" import { SkillSearchTool } from "./skill-search" import { MCP_TOOL_SEARCH_ID, McpToolSearchTool } from "./mcp-tool-search" import * as Tool from "./tool" +import * as ToolSettlement from "./settlement" import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@mimo-ai/plugin" import z from "zod" @@ -37,7 +38,7 @@ import { Log } from "@/util" import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" -import { Effect, Layer, Context } from "effect" +import { Effect, Exit, Layer, Context } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -184,20 +185,30 @@ export const layer = Layer.effect( directory: ctx.directory, worktree: ctx.worktree, } - const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) - const output = typeof result === "string" ? result : result.output - const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) - const info = yield* agent.get(toolCtx.agent) - const out = yield* truncate.output(output, {}, info) - return { - title: "", - output: out.truncated ? out.content : output, - metadata: { - ...metadata, - truncated: out.truncated, - ...(out.truncated && { outputPath: out.outputPath }), - }, - } + return yield* ToolSettlement.promise( + (signal) => def.execute(args as any, { + ...pluginCtx, + abort: AbortSignal.any([toolCtx.abort, signal]), + }), + (exit) => Effect.gen(function* () { + const result = Exit.isSuccess(exit) ? exit.value : yield* Effect.failCause(exit.cause) + const output = typeof result === "string" ? result : result.output + const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) + const info = yield* agent.get(toolCtx.agent) + const out = yield* truncate.output(output, {}, info) + const normalized = { + title: "", + output: out.truncated ? out.content : output, + metadata: { + ...metadata, + truncated: out.truncated, + ...(out.truncated && { outputPath: out.outputPath }), + }, + } + yield* ToolSettlement.record(toolCtx.callID, Effect.succeed(normalized)) + return normalized + }), + ).pipe(Effect.orDie) }), } } diff --git a/packages/cli/src/tool/settlement.ts b/packages/cli/src/tool/settlement.ts new file mode 100644 index 0000000000..8e635597e1 --- /dev/null +++ b/packages/cli/src/tool/settlement.ts @@ -0,0 +1,33 @@ +import { Context, Effect, Exit } from "effect" + +export const ResultRef = Context.Reference<{ + callID: string + result?: Effect.Effect +} | undefined>("~opencode/ToolSettlement", { defaultValue: () => undefined }) + +export const record = (callID: string | undefined, result: Effect.Effect) => + Effect.gen(function* () { + const current = yield* ResultRef + if (current && current.callID === callID) current.result = result + }) + +export function promise( + start: (signal: AbortSignal) => PromiseLike, + consume: (exit: Exit.Exit) => Effect.Effect, +): Effect.Effect { + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { + const controller = new AbortController() + const pending = yield* Effect.try({ + try: () => Promise.resolve(start(controller.signal)).then(Exit.succeed, Exit.fail), + catch: (error) => error, + }) + const wait = Effect.promise(() => pending) + const outcome = yield* restore(wait).pipe(Effect.exit) + if (Exit.isSuccess(outcome)) return yield* consume(outcome.value) + + controller.abort() + const settled = yield* wait + yield* consume(settled) + return yield* Effect.failCause(outcome.cause) + })) +} diff --git a/packages/cli/src/tool/tool-script.ts b/packages/cli/src/tool/tool-script.ts index 32b659b2cc..bae4528cdd 100644 --- a/packages/cli/src/tool/tool-script.ts +++ b/packages/cli/src/tool/tool-script.ts @@ -3,7 +3,7 @@ import os from "os" import fs from "fs" import path from "path" import ts from "typescript" -import { Effect } from "effect" +import { Effect, Exit } from "effect" import type { Tool as AiTool } from "ai" import { EffectBridge, InstanceState } from "@/effect" import { Log, Filesystem, ToolCompat } from "@/util" @@ -15,6 +15,7 @@ import { toolScriptRegistry, TOOL_SCRIPT_ALIASES, TOOL_SCRIPT_EXCLUDED } from ". import type { HarnessMode } from "./gpt" import DESCRIPTION from "./tool-script.txt" import * as Tool from "./tool" +import * as ToolSettlement from "./settlement" import { getToolResultAttachments, getToolResultMetadata } from "./result-error" import { isRecoverableError } from "./recoverable" import * as Truncate from "./truncate" @@ -702,6 +703,22 @@ export const ToolScriptTool = Tool.define( let logBytes = 0 let calls = 0 const withSlot = makeSemaphore(MAX_CONCURRENT) + const controller = new AbortController() + const abort = AbortSignal.any([ctx.abort, controller.signal]) + const hostCalls = new Set>() + let closing = false + const trackHostCall = (fn: HostFn): HostFn => (...args) => { + if (closing || abort.aborted) return Promise.reject(new Error("exec host admission is closed")) + const result = fn(...args) + if (!(result instanceof Promise)) return result + hostCalls.add(result) + return result.finally(() => hostCalls.delete(result)) + } + const closeHostCalls = () => Effect.promise(async () => { + closing = true + controller.abort() + await Promise.allSettled(hostCalls) + }) // Live progress for the TUI: after each settled call, publish the // aggregated counts, complete nested metadata records, and a bounded @@ -800,6 +817,7 @@ export const ToolScriptTool = Tool.define( publishProgress({ immediate: subParts.length === 1 }) const subCtx = { ...ctx, + abort, extra: { ...ctx.extra, fromExec: true }, callID, // Capture nested metadata in its own record. Forwarding it to the @@ -842,7 +860,7 @@ export const ToolScriptTool = Tool.define( tool.execute!(toolArgs ?? {}, { toolCallId: subCtx.callID, messages: [], - abortSignal: ctx.abort, + abortSignal: abort, experimental_context: { onMcpToolProgress: (meta: Record) => bridge.promise( @@ -886,8 +904,9 @@ export const ToolScriptTool = Tool.define( })), ) : executeMcp(mcpDef!) - return withSlot(() => - bridge + return withSlot(() => { + if (closing || abort.aborted) throw new Error("exec tool admission is closed") + return bridge .promise(executeBuiltin) .then( (result) => { @@ -945,8 +964,8 @@ export const ToolScriptTool = Tool.define( publishProgress() throw new Error(`${id}: ${message}`) }, - ), - ) + ) + }) } const logHook: HostFn = (message: unknown) => { @@ -989,14 +1008,15 @@ export const ToolScriptTool = Tool.define( return undefined } - const outcome = yield* Effect.tryPromise({ - try: () => + const outcome = yield* ToolSettlement.promise( + (signal) => { + signal.addEventListener("abort", () => controller.abort(), { once: true }) // The return value is serialized IN THE GUEST via __serialize (strict): // unserializable values (circular refs, BigInt, throwing getters) throw // with a $.path instead of silently degrading to "[object Object]", // and lossy conversions (NaN→null, Map→array, Error→plain object) are // reported as warnings. The envelope crosses the boundary as plain JSON. - evalScript( + return evalScript( `const ALL_TOOLS = Object.freeze(${JSON.stringify(allTools)}.map(Object.freeze));\n` + GUEST_PRELUDE + "\n" + @@ -1004,19 +1024,32 @@ export const ToolScriptTool = Tool.define( `\nconst __ret = await globalThis.__main(); const __out = __serialize(__ret, false); return { __undef: __out.value === undefined, json: __out.value === undefined ? "" : JSON.stringify(__out.value), warnings: __out.warnings };`, - { - __callTool: callTool, - __log: logHook, - __readText: readText, - __writeText: writeText, - }, { + Object.fromEntries(Object.entries({ + __callTool: callTool, + __log: logHook, + __readText: readText, + __writeText: writeText, + }).map(([name, fn]) => [name, trackHostCall(fn)])), { deterministic: false, deadlineMs: WALL_DEADLINE_MS, activeDeadlineMs, - interrupt: () => ctx.abort.aborted, - }), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe(Effect.result) + signal: abort, + }) + }, + (exit) => Effect.gen(function* () { + const interrupted = abort.aborted + yield* closeHostCalls() + yield* flushProgress() + if (interrupted) { + yield* ToolSettlement.record(ctx.callID, Effect.succeed({ + title: "cancelled", + metadata: terminalMetadata("cancelled"), + output: '\nExecution cancelled\n', + })) + } + return Exit.isSuccess(exit) ? exit.value : yield* Effect.failCause(exit.cause) + }), + ).pipe(Effect.result) const traceLines = trace.map( (t) => `- ${t.name} → ${t.status}${t.error ? ` (${t.error.slice(0, 200)})` : ""} [${t.durationMs}ms]`, diff --git a/packages/cli/src/workflow/sandbox.ts b/packages/cli/src/workflow/sandbox.ts index f4b8fa9e78..d37ed031e5 100644 --- a/packages/cli/src/workflow/sandbox.ts +++ b/packages/cli/src/workflow/sandbox.ts @@ -39,6 +39,7 @@ export type SandboxOptions = { /** Optional cooperative cancel: polled from the interrupt handler (guest * bytecode) so an aborted caller can stop a busy guest promptly. */ interrupt?: () => boolean + signal?: AbortSignal } const DEFAULT_DEADLINE_MS = 12 * 60 * 60 * 1000 @@ -104,7 +105,9 @@ globalThis.URL = class URL { * - every QuickJSHandle disposed before context dispose (else process abort) */ export async function evalScript(body: string, hooks: Record, opts: SandboxOptions = {}): Promise { + opts.signal?.throwIfAborted() const QuickJS = await newQuickJSWASMModuleFromVariant(singlefileVariant) + opts.signal?.throwIfAborted() const rt = QuickJS.newRuntime() rt.setMemoryLimit(opts.memoryLimitBytes ?? DEFAULT_MEMORY) // Active-time accounting: charge the guest only while no host hook promise is @@ -130,7 +133,7 @@ export async function evalScript(body: string, hooks: Record, op } const wallDeadline = Date.now() + (opts.deadlineMs ?? DEFAULT_DEADLINE_MS) rt.setInterruptHandler(() => { - if (opts.interrupt?.()) return true + if (opts.signal?.aborted || opts.interrupt?.()) return true if (Date.now() > wallDeadline) return true if (activeBudget === undefined) return false const active = activeAccum + (pending === 0 ? Date.now() - activeStart : 0) @@ -255,8 +258,15 @@ export async function evalScript(body: string, hooks: Record, op opts.deadlineMs ?? DEFAULT_DEADLINE_MS, ) }) + let onAbort: (() => void) | undefined + const aborted = new Promise((_, reject) => { + if (!opts.signal) return + onAbort = () => reject(opts.signal!.reason) + if (opts.signal.aborted) onAbort() + else opts.signal.addEventListener("abort", onAbort, { once: true }) + }) try { - const resolved = await Promise.race([vm.resolvePromise(promiseHandle), deadline]) + const resolved = await Promise.race([vm.resolvePromise(promiseHandle), deadline, aborted]) if (resolved.error) { const err = vm.dump(resolved.error) resolved.error.dispose() @@ -267,6 +277,7 @@ export async function evalScript(body: string, hooks: Record, op } finally { clearTimeout(pumpTimer) clearTimeout(deadlineTimer) + if (onAbort) opts.signal?.removeEventListener("abort", onAbort) } } finally { // Dispose deferreds BEFORE the arena/context: an unsettled deferred still diff --git a/packages/cli/test/history/import.test.ts b/packages/cli/test/history/import.test.ts index 51355f9193..3f5acb8329 100644 --- a/packages/cli/test/history/import.test.ts +++ b/packages/cli/test/history/import.test.ts @@ -7,6 +7,7 @@ import { Global } from "../../src/global" import * as ClaudeImport from "../../src/session/claude-import" import * as CodexImport from "../../src/session/codex-import" import * as OpencodeImport from "../../src/session/opencode-import" +import type { MessageV2 } from "../../src/session/message-v2" import { HistoryFtsTable, HistoryIndexMigrationTable } from "../../src/history/fts.sql" import { ExternalImportTable } from "../../src/session/external-import.sql" import { MessageTable, PartTable, SessionTable } from "../../src/session/session.sql" @@ -89,7 +90,8 @@ for (const source of ["claude", "codex"] as const) { }) } -test("OpenCode: import and source edits maintain search without directory initialization", async () => { +// [TP-SR-R21-32] +test("OpenCode: source edits maintain search and preserve locally continued messages with executed tools", async () => { await using dir = await tmpdir() const file = path.join(dir.path, "source.db") const src = new SQLite(file) @@ -104,6 +106,25 @@ test("OpenCode: import and source edits maintain search without directory initia expect(first.errors).toEqual([]) expect(first.imported).toBe(1) expect(hits("originalneedle")).toEqual({ n: 1 }) + const db = Database.Client() + const tool = { type: "tool", tool: "bash", callID: "call-local", execution: "started", + state: { status: "completed", input: { command: "pwd" }, output: "/tmp/example", title: "Local continuation", + metadata: { exit: 0 }, time: { start: 3, end: 4 } }, metadata: { local: true }, + } satisfies Omit + const insertMessage = db.$client.prepare("INSERT INTO message (id, session_id, agent_id, time_created, time_updated, data) VALUES (?, 'ses_import', 'main', 3, 4, ?)") + insertMessage.run("msg_local_user", JSON.stringify({ role: "user", agent: "build", model: { providerID: "test", modelID: "model" }, time: { created: 3 } })) + insertMessage.run("msg_local_assistant", JSON.stringify({ role: "assistant", parentID: "msg_local_user", time: { created: 3, completed: 4 } })) + const insertPart = db.$client.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, 'ses_import', 3, 4, ?)") + insertPart.run("prt_local_user", "msg_local_user", JSON.stringify({ type: "text", text: "Continue locally" })) + insertPart.run("prt_local_tool", "msg_local_assistant", JSON.stringify(tool)) + const localHistory = () => ({ + messages: db.select().from(MessageTable).all().filter(row => row.id.startsWith("msg_local_")), + parts: db.select().from(PartTable).all().filter(row => row.id.startsWith("prt_local_")), + }) + const before = localHistory() + expect(before.messages).toHaveLength(2) + expect(before.parts).toHaveLength(2) + expect(db.select().from(ExternalImportTable).get()?.message_ids?.map(String)).toEqual(["msg_import"]) src.exec(`UPDATE session SET time_updated = 2; UPDATE part SET data = '{"type":"text","text":"replacementneedle"}';`) const second = await OpencodeImport.run({ dbPath: file }) @@ -111,6 +132,43 @@ test("OpenCode: import and source edits maintain search without directory initia expect(second.resynced).toBe(1) expect(hits("originalneedle")).toEqual({ n: 0 }) expect(hits("replacementneedle")).toEqual({ n: 1 }) + expect(localHistory()).toEqual(before) + expect(db.select().from(PartTable).all().find(row => row.id === "prt_local_tool")?.data).toEqual(tool) + expect(db.select().from(ExternalImportTable).get()).toMatchObject({ message_ids: ["msg_import"], source_mtime: 2 }) + } finally { + src.close() + } +}) + +// [TP-SR-R21-32] +test("OpenCode reimport cannot erase execution proof through message replacement or tool type changes", async () => { + await using dir = await tmpdir() + const file = path.join(dir.path, "proof-source.db") + const src = new SQLite(file) + try { + src.exec(`CREATE TABLE session(id TEXT, directory TEXT, slug TEXT, title TEXT, version TEXT, time_created INTEGER, time_updated INTEGER); + CREATE TABLE message(id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part(id TEXT, message_id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES('ses_proof', '/tmp/example', 'example', 'Example', '1', 1, 1); + INSERT INTO message VALUES('msg_proof', 'ses_proof', 1, 1, '{"role":"assistant"}');`) + const tool = { type: "tool", tool: "write", callID: "call-proof", execution: "not-started", + state: { status: "error", input: {}, inputComplete: false, error: "interrupted", time: { start: 1, end: 2 } } } satisfies Omit + src.prepare("INSERT INTO part VALUES('prt_proof', 'msg_proof', 'ses_proof', 1, 1, ?)").run(JSON.stringify(tool)) + expect((await OpencodeImport.run({ dbPath: file })).errors).toEqual([]) + const db = Database.Client() + expect(db.select().from(PartTable).get()?.data).not.toHaveProperty("execution") + const completed = { ...tool, execution: "started", metadata: { providerExecuted: true }, + state: { status: "completed", input: {}, output: "original result", title: "done", metadata: {}, time: { start: 1, end: 2 } } } satisfies Omit + db.$client.prepare("UPDATE part SET data = ? WHERE id = 'prt_proof'").run(JSON.stringify(completed)) + src.exec("UPDATE session SET time_updated = 2") + expect((await OpencodeImport.run({ dbPath: file })).errors).toEqual([]) + expect(db.select().from(PartTable).get()?.data).toEqual(completed) + src.exec(`UPDATE session SET time_updated = 3; UPDATE part SET data = '{"type":"text","text":"replacement"}'`) + expect((await OpencodeImport.run({ dbPath: file })).errors).toHaveLength(1) + expect(db.select().from(PartTable).get()?.data).toEqual(completed) + src.exec("UPDATE session SET time_updated = 4; DELETE FROM part") + expect((await OpencodeImport.run({ dbPath: file })).errors).toHaveLength(1) + expect(db.select().from(PartTable).get()?.data).toEqual(completed) } finally { src.close() } diff --git a/packages/cli/test/session/message-v2.test.ts b/packages/cli/test/session/message-v2.test.ts index b3285bcb57..facb0aff56 100644 --- a/packages/cli/test/session/message-v2.test.ts +++ b/packages/cli/test/session/message-v2.test.ts @@ -180,6 +180,42 @@ describe("resume trim model boundary", () => { expect(input).toEqual(original) } }) + // [TP-SR-R21-32] + test("only proven unexecuted calls can be discarded before calculating the tool boundary", async () => { + const pending = { ...tool("pending"), execution: "not-started" as const, + state: { status: "error" as const, input: {}, inputComplete: false, error: "interrupted", + metadata: { interrupted: true }, time: { start: 1, end: 2 } } } + const input = history([pending]) + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.removeMessages).toEqual([assistantID]) + expect(trim.messages).toEqual(input.slice(0, 1)) + const completed = tool("completed") + const mixed = MessageV2.planResumeTrim(history([completed, pending]), userID) + expect(mixed.removeParts).toEqual([pending]) + expect(mixed.messages[1].parts).toEqual([completed]) + MessageV2.assertResumeModelBoundary(await MessageV2.toModelMessages(mixed.messages, model)) + for (const execution of [undefined, "started"] as const) { + expect(() => MessageV2.planResumeTrim(history([{ ...pending, execution }]), userID)).toThrow("execution boundary") + } + expect(() => MessageV2.planResumeTrim(history([{ ...pending, metadata: { providerExecuted: true } }]), userID)).toThrow("execution boundary") + const delivered = { ...completed, execution: "not-started" as const } + expect(MessageV2.planResumeTrim(history([delivered]), userID).removeParts).toEqual([]) + const attached = { ...pending, state: { ...pending.state, attachments: [{ ...base, id: PartID.make("result-file"), + type: "file" as const, mime: "image/png", url: `data:image/png;base64,${pngBase64}` }] } } + expect(() => MessageV2.planResumeTrim(history([attached]), userID)).toThrow("execution boundary") + for (const fields of [{ summary: true }, { structured: null }, { finish: "stop" }]) { + const protectedHistory = history([pending]) + protectedHistory[1].info = { ...assistantInfo(assistantID, userID), ...fields } + expect(() => MessageV2.planResumeTrim(protectedHistory, userID)).toThrow("protected output") + } + const block: MessageV2.Part[] = [completed, { ...base, id: PartID.make("later-step"), type: "step-start" }, + { ...base, id: PartID.make("later-text"), type: "text", text: "unfinished" }, pending, + { ...base, id: PartID.make("later-reason"), type: "reasoning", text: "unfinished reasoning", time: { start: 1 } }] + const blocks = MessageV2.planResumeTrim(history(block), userID) + expect(blocks.messages[1].parts).toEqual([completed]) + expect(new Set(blocks.removeParts.map((part) => part.id))).toEqual(new Set(block.slice(1).map((part) => part.id))) + expect(MessageV2.planResumeTrim(blocks.messages, userID).removeParts).toEqual([]) + }) test("APIError on tool carrier cannot silently hide completed observations", async () => { const input = history([tool("completed")]) input[1].info = { ...assistantInfo(assistantID, userID), error: { name: "APIError", data: { message: "unavailable", isRetryable: false } } } diff --git a/packages/cli/test/session/processor-effect.test.ts b/packages/cli/test/session/processor-effect.test.ts index b7152a9416..1a9c1d2ccf 100644 --- a/packages/cli/test/session/processor-effect.test.ts +++ b/packages/cli/test/session/processor-effect.test.ts @@ -1,6 +1,11 @@ import { NodeFileSystem } from "@effect/platform-node" import { beforeEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { appendFile, readFile } from "node:fs/promises" +import { EffectBridge } from "../../src/effect" +import { SyncEvent } from "../../src/sync" +import { Database, eq } from "../../src/storage" +import { EventSequenceTable } from "../../src/sync/event.sql" import path from "path" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -16,6 +21,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionProcessor } from "../../src/session/processor" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionRunState } from "../../src/session/run-state" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util" @@ -162,6 +168,7 @@ const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const deps = Layer.mergeAll( Session.defaultLayer, + SessionRunState.defaultLayer, Snapshot.defaultLayer, AgentSvc.defaultLayer, Permission.defaultLayer, @@ -185,6 +192,224 @@ const boot = Effect.fn("test.boot")(function* () { return { processors, session, provider } }) +// [TP-SR-R21-32] +it.live("execution proof precedes effects, rejects duplicate execution, and survives stale writes", () => + provideTmpdirServer(({ dir }) => Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + const bridge = yield* EffectBridge.make() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "write once") + const message = yield* assistant(chat.id, parent.id, dir) + const model = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ assistantMessage: message, sessionID: chat.id, model }) + handle.registerTool("write") + const file = path.join(dir, "effects.txt") + const body = Effect.gen(function* () { + const part = MessageV2.parts(message.id).find((part) => part.type === "tool") + expect(part).toMatchObject({ execution: "started", state: { status: "running", input: { content: "once" } } }) + yield* Effect.promise(() => appendFile(file, "once\n")) + return { title: "written", output: "saved", metadata: {} } + }) + yield* handle.replay({ + toolCalls: [{ toolName: "write", toolCallId: "write-once", input: { content: "once" } }], + finishReason: "tool-calls", messages: [], + tools: { write: { execute: async () => { + const results = await Promise.allSettled([ + bridge.promise(handle.executeTool("write", "write-once", body)), + bridge.promise(handle.executeTool("write", "write-once", body)), + ]) + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1) + return results.find((result) => result.status === "fulfilled")!.value + } } }, + }) + expect(yield* Effect.promise(() => readFile(file, "utf8"))).toBe("once\n") + const part = MessageV2.parts(message.id).find((part) => part.type === "tool")! + if (part.type !== "tool") throw new Error("missing tool") + const stale = { ...part, execution: "not-started" as const, state: { status: "pending" as const, input: {}, raw: "" } } + yield* session.updatePart(stale) + const saved = yield* session.getPart({ sessionID: chat.id, messageID: message.id, partID: part.id }) + expect(saved).toMatchObject({ execution: "started", state: part.state }) + const late = yield* handle.executeTool("write", "write-once", body).pipe(Effect.exit) + expect(Exit.isFailure(late)).toBe(true) + expect(yield* Effect.promise(() => readFile(file, "utf8"))).toBe("once\n") + }), { git: true, config: (url) => providerCfg(url) }), +) + +// [TP-SR-R21-32] +it.live("closing a processor cancels execution awaiting input and missing persistence prevents effects", () => + provideTmpdirServer(({ dir }) => Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + const bridge = yield* EffectBridge.make() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "do not execute") + const model = yield* provider.getModel(ref.providerID, ref.modelID) + let effects = 0 + for (const scenario of ["waiting", "removed"] as const) { + const message = yield* assistant(chat.id, parent.id, dir) + const handle = yield* processors.create({ assistantMessage: message, sessionID: chat.id, model }) + handle.registerTool("write") + const body = Effect.sync(() => { effects++; return { title: "", output: "unexpected", metadata: {} } }) + if (scenario === "waiting") { + const waiting = yield* handle.executeTool("write", "missing", body).pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* handle.replay({ toolCalls: [], finishReason: "stop", messages: [], tools: {} }) + expect(Exit.isFailure(yield* Fiber.await(waiting))).toBe(true) + } else { + yield* handle.replay({ + toolCalls: [{ toolName: "write", toolCallId: "removed", input: {} }], + finishReason: "tool-calls", messages: [], + tools: { write: { execute: () => bridge.promise(Effect.gen(function* () { + const part = MessageV2.parts(message.id).find((part) => part.type === "tool")! + yield* session.removePart({ sessionID: chat.id, messageID: message.id, partID: part.id }) + return yield* handle.executeTool("write", "removed", body) + })) } }, + }) + } + } + expect(effects).toBe(0) + }), { git: true, config: (url) => providerCfg(url) }), +) + +// [TP-SR-R21-32] +it.live("fork preserves idle execution proof and revokes unexecuted proof while its slice is busy", () => + provideTmpdirServer(({ dir }) => Effect.gen(function* () { + const { session } = yield* boot() + const runs = yield* SessionRunState.Service + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "fork interrupted tools") + const message = yield* assistant(chat.id, parent.id, dir) + for (const execution of ["not-started", "started"] as const) { + const id = PartID.ascending() + yield* session.mutateToolPart({ sessionID: chat.id, messageID: message.id, partID: id }, () => ({ + id, sessionID: chat.id, messageID: message.id, type: "tool", tool: "write", callID: execution, execution, + state: { status: "error", input: {}, inputComplete: execution === "started", error: "interrupted", + metadata: { interrupted: true }, time: { start: 1, end: 2 } }, + })) + } + const idle = yield* session.fork({ sessionID: chat.id }) + const idleTools = (yield* session.messages({ sessionID: idle.id })).flatMap((message) => message.parts).filter((part) => part.type === "tool") + expect(idleTools.map((part) => part.execution)).toEqual(["not-started", "started"]) + yield* runs.start(chat.id, "main", Effect.succeed({ info: message, parts: [] }), Effect.never) + yield* Effect.addFinalizer(() => runs.cancel(chat.id)) + const active = yield* session.fork({ sessionID: chat.id }) + const activeTools = (yield* session.messages({ sessionID: active.id })).flatMap((message) => message.parts).filter((part) => part.type === "tool") + expect(activeTools.map((part) => part.execution)).toEqual([undefined, "started"]) + }), { git: true, config: (url) => providerCfg(url) }), +) + +// [TP-SR-R21-32] +it.live("fork rereads completed execution after its initial history snapshot", () => + provideTmpdirServer(({ dir }) => Effect.gen(function* () { + const { session } = yield* boot() + const runs = yield* SessionRunState.Service + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "fork concurrent execution") + const message = yield* assistant(chat.id, parent.id, dir) + const id = PartID.ascending() + const identity = { sessionID: chat.id, messageID: message.id, partID: id } + const initial: MessageV2.ToolPart = { + id, sessionID: chat.id, messageID: message.id, type: "tool", tool: "write", callID: "fork-concurrent", + execution: "not-started", state: { status: "pending", input: {}, raw: "" }, + } + yield* session.mutateToolPart(identity, () => initial) + const snapshotRead = defer() + const resumeCopy = defer() + const execute = defer() + const file = path.join(dir, "fork-effects.txt") + const result: MessageV2.ToolStateCompleted = { + status: "completed", input: { content: "once" }, output: "FORK_LATEST_RESULT", title: "written", + metadata: { bytes: 5 }, time: { start: 1, end: 2 }, + } + const owner = yield* runs.startOwned(chat.id, "main", Effect.succeed({ info: message, parts: [] }), Effect.gen(function* () { + yield* Effect.promise(() => execute.promise) + yield* session.mutateToolPart(identity, current => ({ + ...current!, execution: "started", state: { status: "running", input: result.input, time: { start: 1 } }, + })) + yield* Effect.promise(() => appendFile(file, "once\n")) + const completed = yield* session.mutateToolPart(identity, current => ({ ...current!, state: result })) + return { info: message, parts: [completed!] } + })) + yield* Effect.addFinalizer(() => owner.interruptOwned) + const originalAssert = runs.assertNotBusy + const mutableRuns = runs as { assertNotBusy: typeof originalAssert } + let held = false + // fork 已读取历史;这里只延迟真实占用检查,复制与持久读取继续使用 Session 实现。 + mutableRuns.assertNotBusy = (sessionID, agentID) => Effect.gen(function* () { + if (sessionID === chat.id && !held) { + held = true + snapshotRead.resolve() + yield* Effect.promise(() => resumeCopy.promise) + } + return yield* originalAssert(sessionID, agentID) + }) + try { + const copying = yield* session.fork({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => snapshotRead.promise) + expect(yield* session.getPart(identity)).toEqual(initial) + expect(Exit.isFailure(yield* originalAssert(chat.id, "main").pipe(Effect.exit))).toBe(true) + execute.resolve() + yield* owner.awaitExit + yield* originalAssert(chat.id, "main") + const completed = MessageV2.ToolPart.parse(yield* session.getPart(identity)) + expect(completed).toMatchObject({ execution: "started", state: result }) + expect(yield* Effect.promise(() => readFile(file, "utf8"))).toBe("once\n") + resumeCopy.resolve() + const forked = yield* Fiber.join(copying) + const messages = yield* session.messages({ sessionID: forked.id }) + const forkUser = messages.find(message => message.info.role === "user")! + const forkAssistant = messages.find(message => message.info.role === "assistant")! + const tools = messages.flatMap(message => message.parts).filter(part => part.type === "tool") + expect(tools).toHaveLength(1) + expect(tools[0]).toEqual({ ...completed, id: tools[0].id, sessionID: forked.id, messageID: forkAssistant.info.id }) + expect(tools[0].id).not.toBe(id) + expect(forkAssistant.info).toMatchObject({ parentID: forkUser.info.id }) + const trim = MessageV2.planResumeTrim(messages, forkUser.info.id) + expect(trim.removeMessages).toEqual([]) + expect(trim.removeParts).toEqual([]) + expect(trim.messages.flatMap(message => message.parts).filter(part => part.type === "tool")).toEqual(tools) + expect(yield* session.getPart(identity)).toEqual(completed) + expect(yield* Effect.promise(() => readFile(file, "utf8"))).toBe("once\n") + } finally { + mutableRuns.assertNotBusy = originalAssert + execute.resolve() + resumeCopy.resolve() + } + }), { git: true, config: (url) => providerCfg(url) }), +) + +// [TP-SR-R21-32] +it.live("external part writes and sync replay cannot create unexecuted proof or erase provider facts", () => + provideTmpdirServer(({ dir }) => Effect.gen(function* () { + const { session } = yield* boot() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "proof provenance") + const message = yield* assistant(chat.id, parent.id, dir) + const part: MessageV2.ToolPart = { + id: PartID.ascending(), sessionID: chat.id, messageID: message.id, + type: "tool", tool: "write", callID: "proof", execution: "not-started", + state: { status: "pending", input: {}, raw: "" }, + } + expect((yield* session.updatePart(part)).execution).toBeUndefined() + const identity = { sessionID: chat.id, messageID: message.id, partID: part.id } + const replay = (value: MessageV2.ToolPart) => Effect.sync(() => { + const latest = Database.use((db) => db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, chat.id)).get()) + SyncEvent.replay({ + id: `event-${PartID.ascending()}`, aggregateID: chat.id, seq: (latest?.seq ?? -1) + 1, + type: SyncEvent.versionedType(MessageV2.Event.PartUpdated.type, MessageV2.Event.PartUpdated.version), + data: { sessionID: chat.id, part: value, time: Date.now() }, + }) + }) + yield* replay(part) + expect(yield* session.getPart(identity)).not.toHaveProperty("execution") + yield* session.mutateToolPart(identity, (current) => ({ ...current!, execution: "started", metadata: { providerExecuted: true } })) + yield* replay({ ...part, metadata: { providerExecuted: false } }) + expect(yield* session.getPart(identity)).toMatchObject({ execution: "started", metadata: { providerExecuted: true } }) + yield* session.updatePart(part) + expect(yield* session.getPart(identity)).toMatchObject({ execution: "started", metadata: { providerExecuted: true } }) + expect(Exit.isFailure(yield* replay({ ...part, callID: "other" }).pipe(Effect.exit))).toBe(true) + }), { git: true, config: (url) => providerCfg(url) }), +) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/packages/cli/test/session/tool-gate-cancel.test.ts b/packages/cli/test/session/tool-gate-cancel.test.ts index 6085509274..4bdeee03b3 100644 --- a/packages/cli/test/session/tool-gate-cancel.test.ts +++ b/packages/cli/test/session/tool-gate-cancel.test.ts @@ -1,8 +1,15 @@ import { expect, spyOn } from "bun:test" import path from "node:path" +import os from "node:os" +import { readFile, realpath, rm } from "node:fs/promises" +import { Filesystem } from "../../src/util" +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import type { Config } from "../../src/config" import { Deferred, Effect, Fiber, Layer } from "effect" import { SessionPrompt } from "../../src/session/prompt" import { Session } from "../../src/session" +import { SessionRunState } from "../../src/session/run-state" import { Bus } from "../../src/bus" import { Question } from "../../src/question" import { MessageV2 } from "../../src/session/message-v2" @@ -17,6 +24,7 @@ const it = testEffect( Layer.mergeAll( SessionPrompt.defaultLayer, Session.defaultLayer, + SessionRunState.defaultLayer, Question.defaultLayer, Bus.defaultLayer, CrossSpawnSpawner.defaultLayer, @@ -161,6 +169,446 @@ it.live( 20000, ) +// [TP-SR-R21-32] +for (const boundary of ["body", "before", "after", "file-before-timeout", "file-after-timeout", "abort-reject"] as const) { + it.live(`Stop waits for registered plugin settlement (${boundary})`, () => Effect.gen(function* () { + const server = startScriptedLLMServer([ + { lines: toolCallsResponse([{ id: "plugin-call", name: "settlement_probe", args: "{}" }]) }, + { lines: textStopResponse("resumed with the original result") }, + ]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance((dir) => Effect.gen(function* () { + const file = (name: string) => path.join(dir, name) + const waitFor = (name: string) => Effect.promise(async () => { + while (!(await Bun.file(file(name)).exists())) await Bun.sleep(10) + }).pipe(Effect.timeout("10 seconds")) + const helper = ` +import { existsSync, writeFileSync, appendFileSync } from "node:fs" +import { setTimeout as sleep } from "node:timers/promises" +import path from "node:path" +const directory = ${JSON.stringify(dir)} +const file = (name) => path.join(directory, name) +async function hold(signal) { + writeFileSync(file("entered"), "yes") + if (signal) { + const aborted = () => writeFileSync(file("aborted"), "yes") + if (signal.aborted) aborted() + else signal.addEventListener("abort", aborted, { once: true }) + } + while (!existsSync(file("release"))) await sleep(10) +} +` + const hookName = boundary.includes("before") ? "tool.execute.before" : "tool.execute.after" + const hook = `async (_input, output) => { + await hold() + ${boundary.includes("after") ? 'output.output = "hook result"; output.metadata.hook = true' : "output.args = {}"} +}` + const body = boundary === "abort-reject" + ? `await new Promise((_, reject) => { + writeFileSync(file("entered"), "yes") + const abort = () => { writeFileSync(file("aborted"), "yes"); reject(new Error("plugin cancelled")) } + if (ctx.abort.aborted) abort() + else ctx.abort.addEventListener("abort", abort, { once: true }) + })` + : `${boundary === "body" ? "await hold(ctx.abort)" : ""} + appendFileSync(file("effects"), "once\\n") + return { output: "real result", metadata: { actual: true } }` + yield* Effect.promise(() => Bun.write(path.join(dir, ".mimocode/plugins/settlement.ts"), `${helper} +export default async () => ({ + tool: { settlement_probe: { description: "Controlled asynchronous test tool", args: {}, + execute: async (_args, ctx) => { ${body} } + } }, + ${boundary === "before" || boundary === "after" ? `${JSON.stringify(hookName)}: ${hook}` : ""} +})`)) + if (boundary.startsWith("file-")) { + yield* Effect.promise(() => Bun.write(path.join(dir, ".mimocode/hooks/settlement.ts"), + `${helper}\nexport default { ${JSON.stringify(hookName)}: ${hook} }`)) + } + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const runs = yield* SessionRunState.Service + const session = yield* sessions.create({ title: "Plugin settlement" }) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + yield* Effect.addFinalizer(() => Effect.promise(() => Bun.write(file("release"), "yes"))) + const running = yield* prompt.prompt({ + sessionID: session.id, agent: "build", harness: "default", + parts: [{ type: "text", text: "Execute the controlled tool" }], + }).pipe(Effect.forkChild) + yield* waitFor("entered") + if (boundary.startsWith("file-")) yield* Effect.sleep("5200 millis") + const tools = () => sessions.messages({ sessionID: session.id }).pipe(Effect.map((messages) => + messages.flatMap((message) => message.parts).filter((part) => part.type === "tool"))) + expect(yield* tools()).toMatchObject([{ execution: "started", state: { status: "running" } }]) + let stopped = false + const stopping = yield* prompt.cancel(session.id).pipe( + Effect.tap(() => Effect.sync(() => { stopped = true })), Effect.forkChild, + ) + if (boundary === "body" || boundary === "abort-reject") yield* waitFor("aborted") + if (boundary !== "abort-reject") { + yield* Effect.sleep("100 millis") + expect(stopped).toBe(false) + expect((yield* runs.assertNotBusy(session.id).pipe(Effect.exit))._tag).toBe("Failure") + expect(yield* tools()).toMatchObject([{ execution: "started", state: { status: "running" } }]) + expect(yield* Effect.promise(() => Bun.file(file("effects")).exists())).toBe(boundary.includes("after")) + yield* Effect.promise(() => Bun.write(file("release"), "yes")) + } + yield* Fiber.join(stopping) + yield* Fiber.join(running) + yield* runs.assertNotBusy(session.id) + const result = (yield* tools())[0] + expect(result.execution).toBe("started") + const completed = boundary === "body" || boundary.includes("after") + expect(result.state.status).toBe(completed ? "completed" : "error") + if (completed) { + expect(result.state).toMatchObject({ + output: boundary === "after" ? "hook result" : "real result", + metadata: { actual: true, truncated: false }, + }) + if (boundary === "file-after-timeout") expect(result.state).not.toHaveProperty("metadata.hook") + expect(yield* Effect.promise(() => Bun.file(file("effects")).text())).toBe("once\n") + yield* prompt.resume({ sessionID: session.id, assistantMessageID: result.messageID, + model: { providerID: "test", modelID: "model" } }) + expect(server.captures).toHaveLength(2) + expect(server.captures[1].messages.at(-1)?.role).toBe("tool") + expect(yield* tools()).toEqual([result]) + expect(yield* Effect.promise(() => Bun.file(file("effects")).text())).toBe("once\n") + } else { + expect(yield* Effect.promise(() => Bun.file(file("effects")).exists())).toBe(false) + } + }), { + git: true, + config: { + enabled_providers: ["test"], model: "test/model", + provider: { test: { + npm: "@ai-sdk/openai-compatible", env: [], + options: { apiKey: "test-key", baseURL: `${server.origin}/v1` }, + models: { model: { name: "Test", tool_call: true, limit: { context: 32000, output: 2000 }, + modalities: { input: ["text"], output: ["text"] } } }, + } }, + agent: { build: { model: "test/model" } }, + permission: { "*": "allow" }, lsp: false, formatter: false, + }, + }) + }), 30000) +} + +function settlementConfig(origin: string): Partial { + return { + enabled_providers: ["test"], model: "test/model", + provider: { test: { + npm: "@ai-sdk/openai-compatible", env: [], + options: { apiKey: "test-key", baseURL: `${origin}/v1` }, + models: { model: { name: "Test", tool_call: true, limit: { context: 32000, output: 2000 }, + modalities: { input: ["text"], output: ["text"] } } }, + } }, + agent: { build: { model: "test/model" } }, + permission: { "*": "allow" }, lsp: false, formatter: false, + } +} + +// [TP-SR-R21-32] +for (const isError of [false, true]) { + it.live(`Stop in MCP after hook retains the actual result and attachments (isError=${isError})`, () => Effect.gen(function* () { + const mcp = new McpServer({ name: "settlement", version: "1" }) + let calls = 0 + mcp.registerTool("probe", { description: "Return an actual MCP observation", inputSchema: {} }, async () => { + calls++ + return { + content: [ + { type: "text" as const, text: "real MCP result" }, + { type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }, + ], + structuredContent: { actual: true }, + isError, + } + }) + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() }) + yield* Effect.promise(() => mcp.connect(transport)) + const endpoint = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => transport.handleRequest(request) }) + yield* Effect.addFinalizer(() => Effect.promise(async () => { await mcp.close(); await endpoint.stop(true) })) + const server = startScriptedLLMServer([ + { lines: toolCallsResponse([{ id: "mcp-call", name: "example_probe", args: "{}" }]) }, + { lines: textStopResponse("continued from MCP observation") }, + ]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance((dir) => Effect.gen(function* () { + const entered = path.join(dir, "after-entered") + const release = path.join(dir, "after-release") + yield* Effect.promise(() => Bun.write(path.join(dir, ".mimocode/plugins/mcp-after.ts"), ` +import { existsSync, writeFileSync } from "node:fs" +import { setTimeout as sleep } from "node:timers/promises" +export default async () => ({ + "tool.execute.after": async (input) => { + if (input.tool !== "example_probe") return + writeFileSync(${JSON.stringify(entered)}, "yes") + while (!existsSync(${JSON.stringify(release)})) await sleep(10) + } +})`)) + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const runs = yield* SessionRunState.Service + const session = yield* sessions.create({ title: "MCP after settlement" }) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + yield* Effect.addFinalizer(() => Effect.promise(() => Bun.write(release, "yes"))) + const running = yield* prompt.prompt({ sessionID: session.id, agent: "build", harness: "default", + parts: [{ type: "text", text: "Call the MCP tool" }] }).pipe(Effect.forkChild) + yield* Effect.promise(async () => { + while (!(await Bun.file(entered).exists())) await Bun.sleep(10) + }).pipe(Effect.timeout("10 seconds")) + expect(calls).toBe(1) + let stopped = false + const stopping = yield* prompt.cancel(session.id).pipe( + Effect.tap(() => Effect.sync(() => { stopped = true })), Effect.forkChild, + ) + yield* Effect.sleep("100 millis") + expect(stopped).toBe(false) + expect((yield* runs.assertNotBusy(session.id).pipe(Effect.exit))._tag).toBe("Failure") + yield* Effect.promise(() => Bun.write(release, "yes")) + yield* Fiber.join(stopping) + yield* Fiber.join(running) + const tools = () => sessions.messages({ sessionID: session.id }).pipe(Effect.map((messages) => + messages.flatMap((message) => message.parts).filter((part) => part.type === "tool"))) + const result = (yield* tools())[0] + expect(result).toMatchObject({ execution: "started", state: { status: isError ? "error" : "completed" } }) + if (result.state.status !== "completed" && result.state.status !== "error") throw new Error("MCP result did not settle") + expect(result.state.status === "completed" ? result.state.output : result.state.error).toContain("real MCP result") + expect(result.state.attachments).toMatchObject([{ type: "file", mime: "image/png", url: "data:image/png;base64,aGVsbG8=" }]) + expect(result.state.metadata).toMatchObject({ mcp: { structuredContent: { actual: true } } }) + yield* prompt.resume({ sessionID: session.id, assistantMessageID: result.messageID, + model: { providerID: "test", modelID: "model" } }) + expect(calls).toBe(1) + expect(yield* tools()).toEqual([result]) + }), { git: true, config: { + ...settlementConfig(server.origin), mcp: { example: { type: "remote", url: endpoint.url.href, oauth: false } }, + } }) + }), 30000) +} + +// [TP-SR-R21-32] +for (const guestCount of [1, 9]) { + it.live(`Stop waits for exec registered plugin guests and closes admission (${guestCount} calls)`, () => Effect.gen(function* () { + const server = startScriptedLLMServer([ + { lines: toolCallsResponse([{ id: "exec-call", name: "exec", args: JSON.stringify({ + code: `return await Promise.all(Array.from({ length: ${guestCount} }, () => tools.settlement_probe({})))`, + }) }]) }, + { lines: textStopResponse("continued from exec observations") }, + ]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance((dir) => Effect.gen(function* () { + const file = (name: string) => path.join(dir, name) + yield* Effect.promise(() => Bun.write(path.join(dir, ".mimocode/plugins/exec-settlement.ts"), ` +import { existsSync, appendFileSync } from "node:fs" +import { setTimeout as sleep } from "node:timers/promises" +const directory = ${JSON.stringify(dir)} +export default async () => ({ + tool: { settlement_probe: { description: "Asynchronous exec guest", args: {}, + execute: async (_args, ctx) => { + appendFileSync(directory + "/started", "once\\n") + const aborted = () => appendFileSync(directory + "/aborted", "once\\n") + if (ctx.abort.aborted) aborted() + else ctx.abort.addEventListener("abort", aborted, { once: true }) + while (!existsSync(directory + "/release")) await sleep(10) + appendFileSync(directory + "/effects", "once\\n") + return { output: "real guest result", metadata: { actual: true } } + } + } } +})`)) + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const runs = yield* SessionRunState.Service + const session = yield* sessions.create({ title: "Exec guest settlement" }) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + yield* Effect.addFinalizer(() => Effect.promise(() => Bun.write(file("release"), "yes"))) + const running = yield* prompt.prompt({ sessionID: session.id, agent: "build", harness: "codex", + parts: [{ type: "text", text: "Run the asynchronous guests through exec" }] }).pipe(Effect.forkChild) + const admitted = Math.min(guestCount, 8) + yield* Effect.promise(async () => { + while (!(await Bun.file(file("started")).exists()) || + (await Bun.file(file("started")).text()).split("\n").length - 1 !== admitted) await Bun.sleep(10) + }).pipe(Effect.timeout("10 seconds")) + let stopped = false + const stopping = yield* prompt.cancel(session.id).pipe( + Effect.tap(() => Effect.sync(() => { stopped = true })), Effect.forkChild, + ) + yield* Effect.promise(async () => { + while (!(await Bun.file(file("aborted")).exists())) await Bun.sleep(10) + }).pipe(Effect.timeout("10 seconds")) + yield* Effect.sleep("100 millis") + expect(stopped).toBe(false) + expect((yield* runs.assertNotBusy(session.id).pipe(Effect.exit))._tag).toBe("Failure") + expect(yield* Effect.promise(() => Bun.file(file("effects")).exists())).toBe(false) + yield* Effect.promise(() => Bun.write(file("release"), "yes")) + yield* Fiber.join(stopping) + yield* Fiber.join(running) + yield* runs.assertNotBusy(session.id) + const tools = () => sessions.messages({ sessionID: session.id }).pipe(Effect.map((messages) => + messages.flatMap((message) => message.parts).filter((part) => part.type === "tool"))) + const result = (yield* tools())[0] + expect(result).toMatchObject({ execution: "started", state: { status: "completed", metadata: { status: "cancelled" } } }) + if (result.state.status !== "completed") throw new Error("Exec observations did not settle") + const nested = result.state.metadata.sub_parts as Array<{ state: { status: string; output?: string; metadata?: unknown } }> + expect(nested).toHaveLength(guestCount) + expect(nested.slice(0, admitted).map((part) => part.state)).toEqual(Array.from({ length: admitted }, () => expect.objectContaining({ + status: "completed", output: "real guest result", metadata: expect.objectContaining({ actual: true }), + }))) + if (guestCount > admitted) expect(nested.at(-1)?.state.status).toBe("error") + expect(yield* Effect.promise(() => Bun.file(file("effects")).text())).toBe("once\n".repeat(admitted)) + expect(yield* Effect.promise(() => Bun.file(file("started")).text())).toBe("once\n".repeat(admitted)) + yield* prompt.resume({ sessionID: session.id, assistantMessageID: result.messageID, + model: { providerID: "test", modelID: "model" } }) + expect(yield* tools()).toEqual([result]) + expect(yield* Effect.promise(() => Bun.file(file("effects")).text())).toBe("once\n".repeat(admitted)) + }), { git: true, config: settlementConfig(server.origin) }) + }), 30000) +} + +// [TP-SR-R21-32] +for (const withGuest of [false, true]) { + it.live(`Stop wakes exec pending JavaScript without further bytecode (completed guest=${withGuest})`, () => Effect.gen(function* () { + const parked = path.join(os.tmpdir(), `exec-parked-${crypto.randomUUID()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => rm(parked, { force: true }))) + const server = startScriptedLLMServer([ + { lines: toolCallsResponse([{ id: "pending-exec", name: "exec", args: JSON.stringify({ + code: `${withGuest ? "await tools.settlement_probe({});" : ""} +await files.writeText(${JSON.stringify(parked)}, "parked"); +return await new Promise(() => {});`, + }) }]) }, + { lines: textStopResponse("continued after pending JavaScript was stopped") }, + ]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance((dir) => Effect.gen(function* () { + const effects = path.join(dir, "effects") + if (withGuest) { + yield* Effect.promise(() => Bun.write(path.join(dir, ".mimocode/plugins/completed-guest.ts"), ` +import { appendFile } from "node:fs/promises" +export default async () => ({ + tool: { settlement_probe: { description: "Complete once before JavaScript waits", args: {}, + execute: async () => { + await appendFile(${JSON.stringify(effects)}, "once\\n") + return { output: "completed guest result", metadata: { actual: true } } + } + } } +})`)) + } + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const runs = yield* SessionRunState.Service + const session = yield* sessions.create({ title: "Pending JavaScript" }) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + const running = yield* prompt.prompt({ sessionID: session.id, agent: "build", harness: "codex", + parts: [{ type: "text", text: "Wait after the selected work" }] }).pipe(Effect.forkChild) + yield* Effect.promise(async () => { + while (!(await Bun.file(parked).exists())) await Bun.sleep(10) + }).pipe(Effect.timeout("10 seconds")) + yield* Effect.sleep("100 millis") + yield* prompt.cancel(session.id).pipe(Effect.timeout("3 seconds")) + yield* Fiber.join(running) + yield* runs.assertNotBusy(session.id) + const tools = () => sessions.messages({ sessionID: session.id }).pipe(Effect.map((messages) => + messages.flatMap((message) => message.parts).filter((part) => part.type === "tool"))) + const result = (yield* tools())[0] + expect(result).toMatchObject({ execution: "started", state: { status: "completed", metadata: { + status: "cancelled", toolCalls: withGuest ? 1 : 0, + } } }) + if (result.state.status !== "completed") throw new Error("Pending exec did not settle") + if (withGuest) { + expect(result.state.metadata.sub_parts).toMatchObject([{ state: { + status: "completed", output: "completed guest result", metadata: { actual: true }, + } }]) + expect(yield* Effect.promise(() => Bun.file(effects).text())).toBe("once\n") + } else expect(result.state.metadata.sub_parts).toEqual([]) + yield* prompt.resume({ sessionID: session.id, assistantMessageID: result.messageID, + model: { providerID: "test", modelID: "model" } }) + expect(server.captures).toHaveLength(2) + expect(yield* tools()).toEqual([result]) + if (withGuest) expect(yield* Effect.promise(() => Bun.file(effects).text())).toBe("once\n") + }), { git: true, config: settlementConfig(server.origin) }) + }), 20000) +} + +// [TP-SR-R21-32] +it.live( + "Stop waits for admitted exec Filesystem.write and Resume does not repeat the write", + () => Effect.gen(function* () { + const root = yield* Effect.promise(() => realpath(os.tmpdir())) + const target = path.join(root, `exec-write-${crypto.randomUUID()}`) + const payload = "real filesystem write completed\n" + yield* Effect.addFinalizer(() => Effect.promise(() => rm(target, { force: true }))) + const server = startScriptedLLMServer([ + { lines: toolCallsResponse([{ id: "raw-file-write", name: "exec", args: JSON.stringify({ + code: `await files.writeText(${JSON.stringify(target)}, ${JSON.stringify(payload)}); return "write completed";`, + }) }]) }, + { lines: textStopResponse("continued after actual file write") }, + ]) + yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) + yield* provideTmpdirInstance(() => Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const runs = yield* SessionRunState.Service + const session = yield* sessions.create({ title: "Raw file settlement" }) + const entered = yield* Deferred.make() + let release!: () => void + const waiting = new Promise(resolve => { release = resolve }) + let writes = 0 + let written = false + const originalWrite = Filesystem.write + const writing = spyOn(Filesystem, "write").mockImplementation(async (...args: Parameters) => { + if (args[0] !== target) return originalWrite(...args) + writes++ + Deferred.doneUnsafe(entered, Effect.void) + await waiting + await originalWrite(...args) + written = true + }) + const entering = spyOn(ToolGate.prototype, "run") + yield* Effect.addFinalizer(() => Effect.sync(() => writing.mockRestore())) + yield* Effect.addFinalizer(() => Effect.sync(() => entering.mockRestore())) + yield* Effect.addFinalizer(() => prompt.cancel(session.id)) + yield* Effect.addFinalizer(() => Effect.sync(release)) + const running = yield* prompt.prompt({ sessionID: session.id, agent: "build", harness: "codex", + parts: [{ type: "text", text: "Write through the raw file interface" }] }).pipe(Effect.forkChild) + yield* Deferred.await(entered).pipe(Effect.timeout("10 seconds")) + const signal = entering.mock.calls.find(([, callID]) => callID === "raw-file-write")?.[3]?.signal + if (!signal) throw new Error("Exec did not enter its cancellation-aware tool gate") + const aborted = new Promise(resolve => signal.addEventListener("abort", () => resolve(), { once: true })) + let stopped = false + const stopping = yield* prompt.cancel(session.id).pipe( + Effect.tap(() => Effect.sync(() => { stopped = true })), Effect.forkChild, + ) + yield* Effect.promise(() => aborted).pipe(Effect.timeout("3 seconds")) + yield* Effect.sleep("100 millis") + expect(stopped).toBe(false) + expect(written).toBe(false) + expect(writes).toBe(1) + expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false) + expect((yield* runs.assertNotBusy(session.id).pipe(Effect.exit))._tag).toBe("Failure") + const before = (yield* sessions.messages({ sessionID: session.id })) + .flatMap((message) => message.parts).find((part) => part.type === "tool") + expect(before).toMatchObject({ execution: "started", state: { status: "running" } }) + release() + yield* Fiber.join(stopping).pipe(Effect.timeout("3 seconds")) + yield* Fiber.join(running) + expect(stopped).toBe(true) + expect(written).toBe(true) + expect(yield* Effect.promise(() => readFile(target, "utf8"))).toBe(payload) + yield* runs.assertNotBusy(session.id) + const tools = () => sessions.messages({ sessionID: session.id }).pipe(Effect.map((messages) => + messages.flatMap((message) => message.parts).filter((part) => part.type === "tool"))) + const result = (yield* tools())[0] + expect(result).toMatchObject({ execution: "started", state: { status: "completed", metadata: { + status: "cancelled", toolCalls: 0, sub_parts: [], + } } }) + yield* prompt.resume({ sessionID: session.id, assistantMessageID: result.messageID, + model: { providerID: "test", modelID: "model" } }) + expect(server.captures).toHaveLength(2) + expect(yield* tools()).toEqual([result]) + expect(writes).toBe(1) + expect(yield* Effect.promise(() => readFile(target, "utf8"))).toBe(payload) + }), { git: true, config: settlementConfig(server.origin) }) + }), + 20000, +) + // [TP-RUN-R4-09] [TP-CA-R8-09] for (const recovery of ["send", "resume", "edit"] as const) { it.live( @@ -289,7 +737,7 @@ for (const recovery of ["send", "resume", "edit"] as const) { ) } -// [TP-SR-R21-28] [TP-SR-R21-30] +// [TP-SR-R21-28] [TP-SR-R21-30] [TP-SR-R21-32] [TP-SR-R21-33] for (const boundary of ["partial", "complete-empty"] as const) { it.live( `Stop preserves the actual tool input boundary (${boundary})`, @@ -310,6 +758,7 @@ for (const boundary of ["partial", "complete-empty"] as const) { : undefined const server = startScriptedLLMServer([ { lines, stream }, + ...(boundary === "partial" ? [{ status: 400, lines: [JSON.stringify({ error: { message: "model unavailable" } })] }] : []), { lines: textStopResponse("continued from a complete tool invocation") }, ]) yield* Effect.addFinalizer(() => Effect.promise(() => server.stop())) @@ -318,7 +767,6 @@ for (const boundary of ["partial", "complete-empty"] as const) { Effect.gen(function* () { const sessions = yield* Session.Service const prompt = yield* SessionPrompt.Service - const bus = yield* Bus.Service const questions = yield* Question.Service const session = yield* sessions.create({ title: "Interrupted input provenance" }) const reached = yield* Deferred.make() @@ -363,22 +811,36 @@ for (const boundary of ["partial", "complete-empty"] as const) { const stoppedTool = carrier.parts.find((part) => part.type === "tool" && part.callID === callID)! expect(stoppedTool.type === "tool" && stoppedTool.state.status).toBe("error") const deleted: string[] = [] - const off = yield* bus.subscribeCallback(MessageV2.Event.Removed, (event) => { + const off = Bus.subscribe(MessageV2.Event.Removed, (event) => { if (event.properties.sessionID === session.id) deleted.push(event.properties.messageID) }) yield* Effect.addFinalizer(() => Effect.sync(off)) + let target = carrier.info.id + if (boundary === "partial") { + yield* prompt.resume({ sessionID: session.id, assistantMessageID: target, + model: { providerID: "test", modelID: "unavailable" } }) + expect(server.captures).toHaveLength(2) + expect(server.captures[1].model).toBe("unavailable") + const failed = (yield* sessions.messages({ sessionID: session.id })).at(-1)! + expect(failed.info.role === "assistant" && failed.info.error).toBeTruthy() + target = failed.info.id + } const resumed = yield* prompt - .resume({ sessionID: session.id, assistantMessageID: carrier.info.id }) + .resume({ sessionID: session.id, assistantMessageID: target, + model: { providerID: "test", modelID: "model" } }) .pipe(Effect.exit) - expect(resumed._tag).toBe(boundary === "partial" ? "Failure" : "Success") + expect(resumed._tag).toBe("Success") expect(stoppedTool).toMatchObject({ + execution: boundary === "partial" ? "not-started" : "started", state: { status: "error", input: {}, inputComplete: boundary === "complete-empty" }, }) const after = yield* sessions.messages({ sessionID: session.id }) if (boundary === "partial") { - expect(server.captures).toHaveLength(1) - expect(after).toEqual(stopped) - expect(deleted).toEqual([]) + expect(server.captures).toHaveLength(3) + expect(server.captures[2].model).toBe("model") + expect(server.captures[2].messages.at(-1)?.role).toBe("user") + expect(after.some((message) => message.info.id === carrier.info.id)).toBe(false) + expect(deleted).toContain(carrier.info.id) } else { expect(server.captures).toHaveLength(2) expect(server.captures[1].messages.at(-1)?.role).toBe("tool") @@ -399,6 +861,12 @@ for (const boundary of ["partial", "complete-empty"] as const) { env: [], options: { apiKey: "test-key", baseURL: `${server.origin}/v1` }, models: { + unavailable: { + name: "Unavailable", + tool_call: true, + limit: { context: 32000, output: 2000 }, + modalities: { input: ["text"], output: ["text"] }, + }, model: { name: "Test", tool_call: true, diff --git a/packages/cli/test/tool/tool-script.test.ts b/packages/cli/test/tool/tool-script.test.ts index f812cf1c5b..2fe2e01125 100644 --- a/packages/cli/test/tool/tool-script.test.ts +++ b/packages/cli/test/tool/tool-script.test.ts @@ -64,6 +64,25 @@ describe("sandbox non-deterministic mode", () => { evalScript(`await pause(); while (true) {}`, hooks, { deterministic: false, interrupt: () => stop }), ).rejects.toThrow() }) + + // [TP-SR-R21-32] + for (const withHostCall of [false, true]) { + test(`AbortSignal stops a pending guest promise without further bytecode (host=${withHostCall})`, async () => { + const entered = Promise.withResolvers() + const controller = new AbortController() + const running = evalScript( + `${withHostCall ? "await completed();" : ""} parked(); return await new Promise(() => {})`, + { completed: async () => "real result", parked: () => entered.resolve() }, + { signal: controller.signal }, + ) + const outcome = running.catch((error: unknown) => error) + await entered.promise + const reason = new Error("cancel pending guest") + controller.abort(reason) + expect(await outcome).toBe(reason) + expect(await evalScript("return 42", {})).toBe(42) + }, 2000) + } }) const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) diff --git a/packages/sdk/src/gen/types.gen.ts b/packages/sdk/src/gen/types.gen.ts index ca2f7261f4..7c995e3c5e 100644 --- a/packages/sdk/src/gen/types.gen.ts +++ b/packages/sdk/src/gen/types.gen.ts @@ -299,6 +299,7 @@ export type ToolPart = { sessionID: string messageID: string type: "tool" + execution?: "not-started" | "started" callID: string tool: string state: ToolState diff --git a/packages/sdk/src/v2/gen/types.gen.ts b/packages/sdk/src/v2/gen/types.gen.ts index 9a9d401223..5dd3d4df6f 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -1264,6 +1264,7 @@ export type ToolPart = { sessionID: string messageID: string type: "tool" + execution?: "not-started" | "started" callID: string tool: string state: ToolState From 056a16e4b72ef720c9a7b4a1670df4a6826097f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 16:58:00 +0800 Subject: [PATCH 06/15] docs(session): reword client-facing comments away from host product names --- packages/cli/src/session/llm.ts | 2 +- packages/cli/src/session/message-v2.ts | 4 ++-- packages/cli/src/session/processor.ts | 2 +- packages/cli/src/session/prompt.ts | 8 ++++---- packages/cli/test/session/message-v2.test.ts | 2 +- packages/cli/test/session/prompt-effect.test.ts | 10 +++++----- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/session/llm.ts b/packages/cli/src/session/llm.ts index 91d9002367..6aff642b16 100644 --- a/packages/cli/src/session/llm.ts +++ b/packages/cli/src/session/llm.ts @@ -310,7 +310,7 @@ const live: Layer.Layer< !input.ephemeral && (yield* actorReg.servesCheckpoint(SessionID.make(input.sessionID), input.agentID)) // replace-agent replaces the PRIMARY line's base prompt with a session-level - // system (desktop execution-profile base). It is a main/peer concern: a + // system (host execution-profile base). It is a main/peer concern: a // subagent shares the sessionID and therefore inherits `systemMode` on the // resolved session prompt, but must keep its OWN `agent.prompt` — else // explore/general/title/… get their identity clobbered by the parent base. diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 1a9ace0852..06566ef209 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -512,7 +512,7 @@ export const HookProvenance = z export type HookProvenance = z.infer /** - * Root-session machine/host envelope (desktop automation, system, scheduled host). + * Root-session machine/host envelope (host automation, system, scheduled host). * Present provenance on source=hook allows non-text parts and does not force * text parts synthetic; absence still fail-closes those checks. */ @@ -1653,7 +1653,7 @@ function fromErrorCore( ...response, }, { cause: original }).toObject() // 字符串直接用原文:JSON.stringify("Internal Server Error") 会多包一层引号, - // 让 SessionRetry/desktop isOpaqueError 精确匹配失效,错误卡把带引号原文吐给用户。 + // 让 SessionRetry and client isOpaqueError 精确匹配失效,错误卡把带引号原文吐给用户。 // 对象仍 stringify 保诊断;undefined/null 走 String()。 const message = typeof e === "string" diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index a8f58a2ec5..595d328cce 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -891,7 +891,7 @@ export const layer: Layer.Layer< error: ctx.assistantMessage.error, }) // Do NOT status.set(idle) here. halt runs before cleanup and the outer - // work ensuring (orphan sweep). Publishing idle first lets the Desktop + // work ensuring (orphan sweep). Publishing idle first lets the client // finish/unsubscribe, then the sweep's part.updated lands on the NEXT // turn's subscription (RL-ORPHAN-D01). Idle is published by // SessionRunState onIdle after the work Effect (including ensuring) diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index d785f100bc..7fd7590deb 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -838,7 +838,7 @@ export const layer = Layer.effect( // R14 terminal inline: quiet abort still persists cancelled notifications // (wake:false skips auto-fork only). Drain parent main inbox NOW so those // rows become synthetic message parts on the parent - // session — desktop live/history both read that part for the terminal pill. + // session — client live and history both read that part for the terminal pill. // drain does NOT start an LLM turn. yield* inbox.drain(sessionID, "main").pipe(Effect.ignore) }) @@ -3170,7 +3170,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) // User-provided images: provenance envelope (synthetic, not tool fiction). - // Insert immediately before the first non-synthetic, non-ignored user content so Desktop + // Insert immediately before the first non-synthetic, non-ignored user content so client // system-reminders / ignored text cannot sit between "## My request:" and the user's text. // Predicate excludes MCP resource sources and synthetic parts (isUserAttachmentImagePart). const userImages = parts.filter((part): part is Extract => @@ -3396,7 +3396,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Sweep orphan tool parts on the busy→idle edge, not only at the next prompt // entry. A tool whose abort finalizer was skipped (crash / process kill / // registration race) stays `running` in the DB after a natural turn end; the - // Desktop UI settles that step as `completed` so the user sees nothing, then + // The client UI settles that step as `completed` so the user sees nothing, then // the next prompt's entry sweep emits `Tool execution aborted` into the NEW // turn's stream. Cleaning on idle closes that window. // Wired via module ref: SessionRunState main-work ensuring invokes the @@ -3477,7 +3477,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: input.agentID ?? "main", task_id: input.task_id, titleLocale: input.titleLocale, - // Trusted user-facing protocol: Desktop/TUI/HTTP/command omit source → user. + // Trusted user-facing protocol: client, TUI, HTTP, or command omitting source → user. // Internal callers must pass explicit non-user source (spawn/hook). source: input.source ?? "user", deferInbox: (input.source ?? "user") === "hook" && input.agentID !== undefined && input.agentID !== "main", diff --git a/packages/cli/test/session/message-v2.test.ts b/packages/cli/test/session/message-v2.test.ts index facb0aff56..701abb867c 100644 --- a/packages/cli/test/session/message-v2.test.ts +++ b/packages/cli/test/session/message-v2.test.ts @@ -1817,7 +1817,7 @@ describe("session.message-v2.fromError", () => { test("plain string errors keep raw text without JSON quote wrapping", () => { // 回归:JSON.stringify("Internal Server Error") 曾多包一层引号, - // 让 SessionRetry/desktop isOpaqueError 精确匹配失效。 + // 让 SessionRetry and client isOpaqueError 精确匹配失效。 const result = MessageV2.fromError("Internal Server Error", { providerID }) expect(result).toStrictEqual({ name: "UnknownError", diff --git a/packages/cli/test/session/prompt-effect.test.ts b/packages/cli/test/session/prompt-effect.test.ts index ad028fcb62..330f72f44b 100644 --- a/packages/cli/test/session/prompt-effect.test.ts +++ b/packages/cli/test/session/prompt-effect.test.ts @@ -946,7 +946,7 @@ it.live("[TP-R4-01][TP-R7-01] uncommitted-hint enabled + hook-source turn does n ) // R003: root-session machine contract — hook + provenance accepts machine payloads. -const machineProvenance = { machine: "desktop-automation" } as const +const machineProvenance = { machine: "host-automation" } as const it.live("[TP-R4-01] machine hook text-only with provenance is accepted and not force-synthetic", () => provideTmpdirServer( @@ -1530,7 +1530,7 @@ it.live("[TP-R4-01] uncommitted-hint delete during wait produces no late hint me 15_000, ) -it.live("[TP-R7-01] uncommitted-hint decision logs land in Desktop WARN-level sink", () => +it.live("[TP-R7-01] uncommitted-hint decision logs land in a WARN-level sink", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm, dir }) { const prompt = yield* SessionPrompt.Service @@ -1548,7 +1548,7 @@ it.live("[TP-R7-01] uncommitted-hint decision logs land in Desktop WARN-level si const prevLogPath = Global.Path.log let warnLogDir = "" try { - // Mirror Desktop engine bootstrap: Log.init({ print: false, level: "WARN" }). + // Mirror host engine bootstrap: Log.init({ print: false, level: "WARN" }). yield* Effect.promise(async () => { const { mkdtempSync } = await import("node:fs") const { tmpdir } = await import("node:os") @@ -2698,7 +2698,7 @@ for (const isError of [false, true]) { ) } -// [TP-RUN-R12-36] Desktop turn-execution: exercise the engine execution boundary, +// [TP-RUN-R12-36] turn-execution: exercise the engine execution boundary, // persistence and model continuation, not a renderer-injected error string. for (const mode of ["throw", "reject", "short"] as const) { const text = mode === "short" ? "short MCP exception" : "MCP exception\n" + "诊断😀 line\n".repeat(30_000) @@ -5691,7 +5691,7 @@ describe("trailing-user resume integration", () => { ) }) -// Desktop turn-execution [TP-RUN-R6-09], session-resume [TP-SR-R16-04]. +// turn-execution [TP-RUN-R6-09], session-resume [TP-SR-R16-04]. for (const boundary of ["snapshot", "language"] as const) { it.live(`cancel during assistant ${boundary} preparation persists user abort before idle and permits the next turn`, () => provideTmpdirServer(({ llm }) => Effect.gen(function* () { From 06e81de9d16e4221ecd6a8f83897b2b21b0bfcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 18:03:57 +0800 Subject: [PATCH 07/15] fix(session): keep delivered resume history and clean empty residue before model resolve - planResumeTrim keeps delivered/protected output and empty containers - drop file-only residue before the protected-output gate - user-resume with no tools keeps delivered siblings, discards incomplete tails - restore parent-scoped cleanupEmptyResidueAssistants before getModel - align admission classify with planResume (tool-resume only with real tools) - allow empty model input for user-resume (parent user may have no parts) --- packages/cli/src/session/message-v2.ts | 26 ++++++++++- packages/cli/src/session/prompt.ts | 49 +++++++++++++++++++- packages/cli/test/session/message-v2.test.ts | 5 +- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 06566ef209..2d3f1f3671 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -884,7 +884,13 @@ export function planResumeTrim(input: WithParts[], parentID: MessageID) { const nextBlock = index === lastToolMessage ? message.parts.findIndex((part, partIndex) => partIndex > lastTool && part.type === "step-start") : -1 - const cut = index < lastToolMessage ? message.parts.length + // No tool in the tail: keep delivered/protected work, discard incomplete + // text-only tails so the request ends at the parent user (no prefill). + const delivered = !!info.summary || info.structured !== undefined || + (!info.error && info.finish && info.finish !== "length" && info.finish !== "tool-calls" && + (info.finish === "stop" || info.time.completed !== undefined)) + const cut = lastToolMessage < 0 ? (delivered ? message.parts.length : 0) + : index < lastToolMessage ? message.parts.length : index === lastToolMessage ? (nextBlock < 0 ? message.parts.length : nextBlock) : 0 const discarded = message.parts.slice(cut) @@ -892,6 +898,20 @@ export function planResumeTrim(input: WithParts[], parentID: MessageID) { part.type === "text" ? !part.ignored && part.text.trim().length > 0 : part.type === "reasoning" ? part.text.trim().length > 0 : part.type === "tool", ) + // File-only / non-model-site shells are residue: drop before the protected + // gate so attachments do not masquerade as delivered output. + const useful = message.parts.some((part) => + part.type === "text" ? !part.synthetic && !part.ignored && part.text.trim().length > 0 + : part.type === "tool" ? true + : part.type === "reasoning" && part.text.trim().length > 0) + const onlyStructural = message.parts.every((part) => + part.type === "step-start" || part.type === "step-finish" || part.type === "retry" || + part.type === "text" && (part.ignored || !part.text.trim().length) || + part.type === "reasoning" && !part.text.trim().length) + if (!useful && !onlyStructural && message.parts.length > 0 && info.structured === undefined && !info.summary) { + removeMessages.push(info.id) + return [] + } if (cut < message.parts.length && ( info.summary || info.structured !== undefined || (!info.error && info.finish && info.finish !== "length" && info.finish !== "tool-calls" && @@ -949,7 +969,9 @@ export function assertResumeModelBoundary(messages: ModelMessage[]) { if (messages.at(-1)?.role === "assistant") { throw new Error("Resume requires a user or complete client tool-result boundary") } - if (messages.length === 0) throw new Error("Resume has no model input") + // Empty is legal for user-resume: the parent user may carry no parts and + // runLoop re-seeds the turn from that user. Only tool-pairing and tail shape + // must hold when there is model input. } export const toModelMessagesEffect = Effect.fnUntraced(function* ( diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 7fd7590deb..423f986382 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -3517,6 +3517,36 @@ NOTE: At any point in time through this workflow you should feel free to ask the const isEmptyAssistantResidue = (assistant: MessageV2.Assistant, parts: readonly MessageV2.Part[]) => assistant.structured === undefined && !assistant.summary && !hasUsefulAssistantParts(parts) + /** + * Delete empty-residue assistants under one parent user. + * Parent-scoped: never session-wide. Live empty shells (busy, no terminal + * marker) are skipped; `preserveError` keeps error-marked shells for recovery. + */ + const cleanupEmptyResidueAssistants = Effect.fn("SessionPrompt.cleanupEmptyResidueAssistants")(function* (input: { + sessionID: SessionID + agentID?: string + parentMessageID: MessageID + sessionBusy?: boolean + force?: boolean + preserveError?: boolean + }) { + const busy = input.sessionBusy ?? (yield* status.get(input.sessionID)).type !== "idle" + const preserveError = input.preserveError ?? true + const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: input.agentID ?? "main" }) + const removed: MessageID[] = [] + for (const msg of msgs) { + if (msg.info.role !== "assistant") continue + if (msg.info.parentID !== input.parentMessageID) continue + if (!isEmptyAssistantResidue(msg.info, msg.parts)) continue + if (preserveError && msg.info.error) continue + const liveOwned = !input.force && busy && !msg.info.error && !("completed" in msg.info.time) + if (liveOwned) continue + yield* sessions.removeMessage({ sessionID: input.sessionID, messageID: msg.info.id }) + removed.push(msg.info.id) + } + return removed + }) + /** * Provider stop alone is not a valid delivery: empty stops can resume from * their parent even after completion was persisted, before classification. @@ -6250,7 +6280,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const parentMessageID = target.info.parentID - if (!isEmptyAssistantResidue(target.info, target.parts)) { + // tool-resume only when the target has real tool calls to keep. Text-only + // incomplete assistants fall back to user-resume (no prefill; D16.3). + const hasToolParts = target.parts.some((part) => part.type === "tool") + if (!isEmptyAssistantResidue(target.info, target.parts) && hasToolParts) { const tool: ResumePlan = { action: "tool-resume", assistantMessageID: target.info.id, @@ -6323,7 +6356,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the tail.info.id !== plan.assistantMessageID || tail.info.parentID !== plan.parentMessageID || !isRecoveryWorthyAssistant(tail.info, tail.parts) || - isEmptyAssistantResidue(tail.info, tail.parts) !== (plan.action === "user-resume") + // Same classify as planResume: tool-resume only with real tool parts. + ((!isEmptyAssistantResidue(tail.info, tail.parts) && tail.parts.some((p) => p.type === "tool")) !== (plan.action === "tool-resume")) ) ? "assistant changed at runner admission" : undefined @@ -6335,6 +6369,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the trim = yield* Effect.sync(() => MessageV2.planResumeTrim(tailMsgs, plan.parentMessageID)).pipe( Effect.catchCause(() => Effect.sync(() => { throw new NotFoundError({ message: "No safe resume history boundary" }) })), ) + // Empty residue is dirty tail data: clean it before model resolution so a + // later getModel failure cannot leave shells that block re-dispatch. + if (!strictTail) { + yield* cleanupEmptyResidueAssistants({ + sessionID: input.sessionID, + agentID: input.agentID, + parentMessageID: plan.parentMessageID, + sessionBusy: false, + preserveError: false, + }) + } const parent = tailMsgs[parentIdx].info as MessageV2.User trimModel = yield* getModel( input.model ? ProviderID.make(input.model.providerID) : parent.model.providerID, diff --git a/packages/cli/test/session/message-v2.test.ts b/packages/cli/test/session/message-v2.test.ts index 701abb867c..5e3a8937f4 100644 --- a/packages/cli/test/session/message-v2.test.ts +++ b/packages/cli/test/session/message-v2.test.ts @@ -238,7 +238,10 @@ describe("resume trim model boundary", () => { test(`protect delivered output ${JSON.stringify(protectedInfo)}`, () => { const input = history([{ ...base, id: PartID.make("text"), type: "text", text: "delivered" }]) input[1].info = { ...assistantInfo(assistantID, userID), ...protectedInfo } - expect(() => MessageV2.planResumeTrim(input, userID)).toThrow("protected output") + // Delivered output is kept, never silently dropped from durable history. + const trim = MessageV2.planResumeTrim(input, userID) + expect(trim.removeMessages).toEqual([]) + expect(trim.messages[1].parts).toEqual(input[1].parts) }) } }) From fe364ff08db13046f2abec906c16c62b8caa8d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 18:16:17 +0800 Subject: [PATCH 08/15] fix(session): restore text-content tool-resume classify for empty-stop matrix --- packages/cli/src/session/prompt.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 423f986382..32fda4b9ea 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -6280,10 +6280,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const parentMessageID = target.info.parentID - // tool-resume only when the target has real tool calls to keep. Text-only - // incomplete assistants fall back to user-resume (no prefill; D16.3). - const hasToolParts = target.parts.some((part) => part.type === "tool") - if (!isEmptyAssistantResidue(target.info, target.parts) && hasToolParts) { + if (!isEmptyAssistantResidue(target.info, target.parts)) { const tool: ResumePlan = { action: "tool-resume", assistantMessageID: target.info.id, @@ -6356,8 +6353,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the tail.info.id !== plan.assistantMessageID || tail.info.parentID !== plan.parentMessageID || !isRecoveryWorthyAssistant(tail.info, tail.parts) || - // Same classify as planResume: tool-resume only with real tool parts. - ((!isEmptyAssistantResidue(tail.info, tail.parts) && tail.parts.some((p) => p.type === "tool")) !== (plan.action === "tool-resume")) + isEmptyAssistantResidue(tail.info, tail.parts) !== (plan.action === "user-resume") ) ? "assistant changed at runner admission" : undefined From c8ebe0425b72a48d0215621a861db52fd95794ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 19:45:03 +0800 Subject: [PATCH 09/15] fix(session): retain empty resume containers and clean only sibling residue - user-resume cleans sibling empty residue, keeps the resume target (TP-SR-R21-27) - empty-stop matrix: empty target stays, placeholders/text tails still trimmed - resume-empty-residue asserts durable empty containers per D16.3 --- packages/cli/src/session/prompt.ts | 12 +++++++++--- .../cli/test/session/resume-empty-residue.test.ts | 11 ++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 32fda4b9ea..58e2fb94e7 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -3529,6 +3529,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionBusy?: boolean force?: boolean preserveError?: boolean + exceptMessageID?: MessageID }) { const busy = input.sessionBusy ?? (yield* status.get(input.sessionID)).type !== "idle" const preserveError = input.preserveError ?? true @@ -3537,6 +3538,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the for (const msg of msgs) { if (msg.info.role !== "assistant") continue if (msg.info.parentID !== input.parentMessageID) continue + if (input.exceptMessageID && msg.info.id === input.exceptMessageID) continue if (!isEmptyAssistantResidue(msg.info, msg.parts)) continue if (preserveError && msg.info.error) continue const liveOwned = !input.force && busy && !msg.info.error && !("completed" in msg.info.time) @@ -6365,15 +6367,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the trim = yield* Effect.sync(() => MessageV2.planResumeTrim(tailMsgs, plan.parentMessageID)).pipe( Effect.catchCause(() => Effect.sync(() => { throw new NotFoundError({ message: "No safe resume history boundary" }) })), ) - // Empty residue is dirty tail data: clean it before model resolution so a - // later getModel failure cannot leave shells that block re-dispatch. - if (!strictTail) { + // Empty residue is dirty tail data on user-resume: clean it before model + // resolution so a later getModel failure cannot leave shells that block + // re-dispatch. tool-resume keeps durable empty containers intact. + if (plan.action === "user-resume" && !strictTail) { + // Keep the resume target itself (TP-SR-R21-27 empty container stays + // durable); only sibling empty residue blocks re-dispatch. yield* cleanupEmptyResidueAssistants({ sessionID: input.sessionID, agentID: input.agentID, parentMessageID: plan.parentMessageID, sessionBusy: false, preserveError: false, + exceptMessageID: plan.assistantMessageID, }) } const parent = tailMsgs[parentIdx].info as MessageV2.User diff --git a/packages/cli/test/session/resume-empty-residue.test.ts b/packages/cli/test/session/resume-empty-residue.test.ts index 8be25b30e6..a30e59d8f4 100644 --- a/packages/cli/test/session/resume-empty-residue.test.ts +++ b/packages/cli/test/session/resume-empty-residue.test.ts @@ -88,7 +88,8 @@ describe("resume empty residue", () => { expect(result.parents.every((p) => p === result.userParent)).toBe(true) // [TP-SR-R21-16] expect(result.shell1Gone).toBe(true) - expect(result.shell2Gone).toBe(true) + // Resume target empty container stays durable (TP-SR-R21-27). + expect(result.shell2Gone).toBe(false) expect(result.anyAbandonedAsResumed).toBe(false) }) @@ -206,7 +207,7 @@ describe("resume empty residue", () => { }), ), }) - expect(result.emptyGone).toBe(true) + expect(result.emptyGone).toBe(false) // D16.3: empty containers stay durable expect(result.usefulKept).toBe(true) }) @@ -273,7 +274,7 @@ describe("resume empty residue", () => { }), ), }) - expect(result.emptyGone).toBe(true) + expect(result.emptyGone).toBe(false) // D16.3: empty containers stay durable expect(result.usefulNotResumeTarget).toBe(true) expect(result.users).toBe(1) }) @@ -347,7 +348,7 @@ describe("resume empty residue", () => { }), ), }) - expect(result.emptyGone).toBe(true) + expect(result.emptyGone).toBe(false) // D16.3: empty containers stay durable expect(result.completedKept).toBe(true) expect(result.usersStable).toBe(true) expect(result.ranSomething).toBe(true) @@ -471,6 +472,6 @@ describe("resume empty residue", () => { ), }) expect(result.listed).toBe(true) - expect(result.shellGone).toBe(true) + expect(result.shellGone).toBe(false) // file-only shell still user-resume; container retained }) }) From 62b7b59fdbe47c3821fcb0e7be6173b6bed99521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 20:52:18 +0800 Subject: [PATCH 10/15] test(actor): keep continuation LLM replies from title-generation theft - question isolation: handle p1/p2 rejections immediately (no unhandled rejection) - observeWake waits on running/idle status instead of ActorExecution.current - continuation second result uses textMatch so title generation cannot steal it --- .../test/actor/cancel-notification.test.ts | 30 ++++++++++++------- packages/cli/test/question/question.test.ts | 4 +++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/cli/test/actor/cancel-notification.test.ts b/packages/cli/test/actor/cancel-notification.test.ts index e652cda80e..f54c7f530e 100644 --- a/packages/cli/test/actor/cancel-notification.test.ts +++ b/packages/cli/test/actor/cancel-notification.test.ts @@ -294,16 +294,21 @@ const captureParentInbox = (parentID: SessionID, parentActorID = "main") => }) const observeWake = (sessionID: SessionID, actorID: string) => Effect.gen(function* () { - const executions = yield* ActorExecution.Service const instance = Instance.current - const started = yield* Deferred.make() + const started = yield* Deferred.make() + const finished = yield* Deferred.make() + let sawRunning = false const onStatus = (event: GlobalEvent) => { if (event.directory !== instance.directory || event.project !== instance.project.id || event.payload.type !== ActorStatusChanged.type) return const properties = ActorStatusChanged.properties.parse(event.payload.properties) - if (properties.sessionID !== sessionID || properties.actorID !== actorID || properties.status !== "running") return - Effect.runSync(executions.current(sessionID, actorID).pipe( - Effect.flatMap((execution) => Deferred.succeed(started, execution)), - )) + if (properties.sessionID !== sessionID || properties.actorID !== actorID) return + if (properties.status === "running" && !sawRunning) { + sawRunning = true + Effect.runSync(Deferred.succeed(started, undefined)) + } + if (sawRunning && properties.status === "idle") { + Effect.runSync(Deferred.succeed(finished, undefined)) + } } yield* Effect.acquireRelease( Effect.sync(() => GlobalBus.on("event", onStatus)), @@ -312,9 +317,8 @@ const observeWake = (sessionID: SessionID, actorID: string) => Effect.gen(functi return Effect.gen(function* () { // Initial idle is not evidence of a completed wake; wait for this turn to // start, then for execution release before another notification can arrive. - const execution = yield* Deferred.await(started) - expect(execution).toBeDefined() - yield* Deferred.await(execution!.done) + yield* Deferred.await(started) + yield* Deferred.await(finished) }) }) @@ -375,7 +379,11 @@ describe("Actor cancel notification (T41 unified terminal-status bridge)", () => yield* firstWake const continuedWake = yield* observeWake(parent.id, "owner") const before = yield* llm.calls - if (terminal === "success") yield* llm.text("second result") + if (terminal === "success") { + // Match the continuation turn only — a plain queue entry can be + // stolen by a title-generation request (see llm-server isTitleRequest). + yield* llm.textMatch(({ body }) => JSON.stringify(body.messages).includes('"content":"continue"'), "second result") + } if (terminal === "failure") yield* llm.error(401, { error: { message: "invalid credential", type: "authentication_error" } }) if (terminal === "cancelled") yield* llm.hang yield* prompt.prompt({ sessionID: spawned.sessionID, agentID: spawned.actorID, agent: "build", model: ref, noReply: true, parts: [{ type: "text", text: "continue" }] }) @@ -422,7 +430,7 @@ describe("Actor cancel notification (T41 unified terminal-status bridge)", () => yield* mainNotifications(0, spawned.actorID) } }), { git: true, config: providerCfg }, - )) + ), 30_000) } } diff --git a/packages/cli/test/question/question.test.ts b/packages/cli/test/question/question.test.ts index 62282f05e8..d84b30a17b 100644 --- a/packages/cli/test/question/question.test.ts +++ b/packages/cli/test/question/question.test.ts @@ -356,6 +356,9 @@ test("questions stay isolated by directory", async () => { ], }), }) + // Handle rejection immediately so a later reject() cannot surface as an + // unhandled rejection and fail this test. + p1.catch(() => {}) const p2 = Instance.provide({ directory: two.path, @@ -371,6 +374,7 @@ test("questions stay isolated by directory", async () => { ], }), }) + p2.catch(() => {}) const onePending = await Instance.provide({ directory: one.path, From b7a8b0717e4a81cc231d89c9de16b2993f4c298d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Tue, 13 Oct 2026 21:07:42 +0800 Subject: [PATCH 11/15] test(actor): pin continuation failure reply to the continuation request Title-generation probes were stealing the queued 401 and letting the continuation succeed. Use pushMatch like the success path. --- packages/cli/test/actor/cancel-notification.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/actor/cancel-notification.test.ts b/packages/cli/test/actor/cancel-notification.test.ts index f54c7f530e..78f6e3aa9b 100644 --- a/packages/cli/test/actor/cancel-notification.test.ts +++ b/packages/cli/test/actor/cancel-notification.test.ts @@ -56,7 +56,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { TestLLMServer } from "../lib/llm-server" +import { TestLLMServer, httpError } from "../lib/llm-server" import { Inbox } from "../../src/inbox" import { InboxTable, type InboxRow } from "../../src/inbox/inbox.sql" @@ -384,7 +384,14 @@ describe("Actor cancel notification (T41 unified terminal-status bridge)", () => // stolen by a title-generation request (see llm-server isTitleRequest). yield* llm.textMatch(({ body }) => JSON.stringify(body.messages).includes('"content":"continue"'), "second result") } - if (terminal === "failure") yield* llm.error(401, { error: { message: "invalid credential", type: "authentication_error" } }) + if (terminal === "failure") { + // Same as success: keep the 401 on the continuation request so a + // title-generation probe cannot steal it. + yield* llm.pushMatch( + ({ body }) => JSON.stringify(body.messages).includes('"content":"continue"'), + httpError(401, { error: { message: "invalid credential", type: "authentication_error" } }), + ) + } if (terminal === "cancelled") yield* llm.hang yield* prompt.prompt({ sessionID: spawned.sessionID, agentID: spawned.actorID, agent: "build", model: ref, noReply: true, parts: [{ type: "text", text: "continue" }] }) const execution = yield* prompt.loop({ sessionID: spawned.sessionID, agentID: spawned.actorID, notifyParentOnComplete: true }).pipe(Effect.exit, Effect.forkChild) From fecc326d93c4360eb0aa4acd17dd848b6ca1288b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Wed, 14 Oct 2026 03:04:34 +0800 Subject: [PATCH 12/15] fix(retry): publish request-phase session.status so the reconnect row is visible Request-ladder retries were diagnostics-only after 11093c90, so long server/network waits left Desktop on a dead spinner. Write SessionStatus from the request ladder via processor-owned setRetry (same map GET /session/status serves), and stop later busy frames from clobbering it. Co-Authored-By: MiMo --- packages/cli/src/session/llm.ts | 35 ++++++-- packages/cli/src/session/processor.ts | 35 +++++++- packages/cli/src/session/prompt.ts | 5 +- packages/cli/src/session/run-state.ts | 9 +- .../cli/test/session/processor-effect.test.ts | 89 ++++++++++--------- .../retry-density-invalid-baseurl.test.ts | 7 +- .../retry-status-density-no-maxmode.test.ts | 12 +-- .../cli/test/session/status-retry-map.test.ts | 40 +++++++++ 8 files changed, 170 insertions(+), 62 deletions(-) create mode 100644 packages/cli/test/session/status-retry-map.test.ts diff --git a/packages/cli/src/session/llm.ts b/packages/cli/src/session/llm.ts index 6aff642b16..b5870f912f 100644 --- a/packages/cli/src/session/llm.ts +++ b/packages/cli/src/session/llm.ts @@ -201,6 +201,15 @@ export type StreamInput = { ephemeral?: boolean requestID?: string assistantMessageID?: string + /** Request-ladder visible wait. Processor owns SessionStatus (shared with GET /session/status). */ + onRetryStatus?: (info: { + attempt: number + message: string + next: number + phase?: "request" | "stream" + scope?: "request" | "live-step" | "max-candidate" | "max-judge" + hostCode?: string + }) => Effect.Effect } export type StreamRequest = StreamInput & { @@ -966,14 +975,26 @@ const live: Layer.Layer< return Stream.failCause(primaryCause) return Stream.unwrap( Effect.gen(function* () { - // Request-phase ladders nest inside processor stream retries. Publishing - // session.status{retry} here restarts a 200ms×4 burst on every outer - // cycle; measured with unreachable baseURL: 4 request + 1 stream per - // cycle ≈ 20 UI frames in 32s (looks nothing like exponential backoff). - // Session status is owned by processor (user-visible wait); request - // attempts stay on Session.Event.RetryAttempt for diagnostics only — - // unless the caller is propose-only ensemble (quietRetryDiagnostics). + // Recoverable kinds (network / rate_limit / server) use persistent + // budgets and the request-phase ladder is often the only waiter the + // user can observe — processor stream retries never see the failure. + // Publish session.status{retry} here so Desktop can show the + // reconnect row instead of a dead spinner. Attempts stay on + // Session.Event.RetryAttempt as well for diagnostics. // The Promise bridge would reattach ambient ALS instead of this fiber's InstanceRef. + const ownsMainStatus = !input.ephemeral && (!input.agentID || input.agentID === "main") + if (ownsMainStatus && input.onRetryStatus) { + // Processor-owned SessionStatus (same map GET /session/status serves), + // so reload/hydrate still shows the reconnect row. + yield* input.onRetryStatus({ + attempt: nextAttempt, + message: decision.message, + next: Date.now() + wait, + phase: "request", + scope: decision.scope, + hostCode: decision.hostCode, + }).pipe(Effect.ignore) + } if (!input.ephemeral && !input.quietRetryDiagnostics) yield* bus.publish(Session.Event.RetryAttempt, { sessionID: SessionID.make(input.sessionID), messageID: input.user.id, diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 595d328cce..2ddcb11103 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -226,6 +226,8 @@ interface ProcessorContext extends Input { textNgramRepeat: boolean textPartPersisted: boolean retrySafe: boolean + /** First provider frame of this process() already marked busy; later retry frames must not clobber session.status{retry}. */ + startedBusy: boolean } type StreamEvent = Event @@ -301,6 +303,7 @@ export const layer: Layer.Layer< textNgramRepeat: false, textPartPersisted: false, retrySafe: true, + startedBusy: false, } let aborted = false // Only the main agent owns session-level status. Subagents (explore, @@ -488,10 +491,17 @@ export const layer: Layer.Layer< const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": - if (isMain) yield* status.set(ctx.sessionID, { type: "busy" }) + // First provider frame of this process() marks busy. Later frames belong + // to request-ladder retries of the same attempt and must not clobber + // session.status{retry} (the reconnect row is the only user-visible wait). + if (isMain && !ctx.startedBusy) { + ctx.startedBusy = true + yield* status.set(ctx.sessionID, { type: "busy" }) + } return case "reasoning-start": + if (isMain) yield* status.set(ctx.sessionID, { type: "busy" }) if (value.id in ctx.reasoningMap) return ctx.reasoningMap[value.id] = { id: PartID.ascending(), @@ -535,6 +545,7 @@ export const layer: Layer.Layer< return case "tool-input-start": + if (isMain) yield* status.set(ctx.sessionID, { type: "busy" }) if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } @@ -740,6 +751,7 @@ export const layer: Layer.Layer< } case "text-start": + if (isMain) yield* status.set(ctx.sessionID, { type: "busy" }) ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -917,9 +929,28 @@ export const layer: Layer.Layer< ready.clear() } ctx.retrySafe = true + ctx.startedBusy = false ctx.textNgramRepeat = false ctx.textNgramMonitor = createTextNgramMonitor() - const stream = llm.stream({ ...streamInput, assistantMessageID: ctx.assistantMessage.id }) + const stream = llm.stream({ + ...streamInput, + assistantMessageID: ctx.assistantMessage.id, + // Request-ladder retries must write SessionStatus (not just bus events): + // GET /session/status serves the same map after renderer reload. + onRetryStatus: (info) => + isMain + ? status.setRetry(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + phaseAttempt: info.attempt, + message: info.message, + next: info.next, + phase: info.phase, + scope: info.scope, + hostCode: info.hostCode, + }).pipe(Effect.asVoid) + : Effect.void, + }) yield* stream.pipe( Stream.tap(handleEvent), diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 58e2fb94e7..5aa9227cc4 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -4453,9 +4453,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the loopStreakCropped = false // F55: only main agent sets session status to busy; subagent runners // must not touch session-level status (Runner.onBusy is Effect.void - // for non-main actors per F47). + // for non-main actors per F47). Do not clobber an in-flight retry row. if (!agentID || agentID === "main") { - yield* status.set(sessionID, { type: "busy" }) + const current = yield* status.get(sessionID) + if (current.type !== "retry") yield* status.set(sessionID, { type: "busy" }) } if (!deferInbox) yield* inbox.drain(sessionID, agentID ?? "main").pipe(Effect.ignore) yield* slog.info("loop", { step }) diff --git a/packages/cli/src/session/run-state.ts b/packages/cli/src/session/run-state.ts index 0be92ea888..c0bf12e2a9 100644 --- a/packages/cli/src/session/run-state.ts +++ b/packages/cli/src/session/run-state.ts @@ -100,7 +100,14 @@ export const layer = Layer.effect( // (Cancelling-wait is agent-agnostic in Runner.ensureRunning). // Session idle status is main-only (actors do not publish session idle). onIdle: isMain ? status.set(sessionID, { type: "idle" }) : Effect.void, - onBusy: isMain ? status.set(sessionID, { type: "busy" }) : Effect.void, + // Do not clobber session.status{retry} with busy: request-ladder retries + // re-enter the runner and would wipe the reconnect row mid-wait. + onBusy: isMain + ? Effect.gen(function* () { + const current = yield* status.get(sessionID) + if (current.type !== "retry") yield* status.set(sessionID, { type: "busy" }) + }) + : Effect.void, // Child executors must observe cancellation, not a stale assistant. onInterrupt: isMain ? onInterrupt : Effect.interrupt, busy: () => new Session.BusyError(sessionID), diff --git a/packages/cli/test/session/processor-effect.test.ts b/packages/cli/test/session/processor-effect.test.ts index 1a9c1d2ccf..ec7a92c7bd 100644 --- a/packages/cli/test/session/processor-effect.test.ts +++ b/packages/cli/test/session/processor-effect.test.ts @@ -642,51 +642,55 @@ it.live("session.processor effect tests capture reasoning from http mock", () => ), ) -it.live("session.processor effect tests reset reasoning state across retries", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const { processors, session, provider } = yield* boot() +it.live( + "session.processor effect tests reset reasoning state across retries", + () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() - yield* llm.push(reply().reason("one").reset(), reply().reason("two").stop()) + yield* llm.push(reply().reason("one").reset(), reply().reason("two").stop()) - const chat = yield* session.create({}) - const parent = yield* user(chat.id, "reason") - const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const mdl = yield* provider.getModel(ref.providerID, ref.modelID) - const handle = yield* processors.create({ - assistantMessage: msg, - sessionID: chat.id, - model: mdl, - }) + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "reason") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) - const value = yield* handle.process({ - user: { - id: parent.id, + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, sessionID: chat.id, - role: "user", - time: parent.time, - agent: parent.agent, - model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, - sessionID: chat.id, - model: mdl, - agent: agent(), - system: [], - messages: [{ role: "user", content: "reason" }], - tools: {}, - }) + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "reason" }], + tools: {}, + }) - const parts = MessageV2.parts(msg.id) - const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning") + const parts = MessageV2.parts(msg.id) + const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning") - expect(value).toBe("continue") - expect(yield* llm.calls).toBe(2) - expect(reasoning.some((part) => part.text === "two")).toBe(true) - expect(reasoning.some((part) => part.text === "onetwo")).toBe(false) - }), - { git: true, config: (url) => providerCfg(url) }, - ), + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(reasoning.some((part) => part.text === "two")).toBe(true) + expect(reasoning.some((part) => part.text === "onetwo")).toBe(false) + }), + { git: true, config: (url) => providerCfg(url) }, + ), + // Connection-reset stream retry waits the network/stream backoff (~2–5s) before the second call. + 15_000, ) it.live("session.processor effect tests do not retry unknown json errors", () => @@ -843,10 +847,9 @@ it.live( expect(yield* llm.calls).toBe(3) // Recoverable kinds (network/rate_limit/server) use persistent budgets and // ignore phase=request / request.maxRetries. Both 503s are absorbed on the - // llm-side server ladder, so the processor never enters a stream-phase - // retry and does not publish session.status{retry} here. That is the - // intended density outcome: fewer UI frames, not more. - expect(states).toStrictEqual([]) + // llm-side server ladder. Request-phase now publishes session.status{retry} + // so Desktop can show the reconnect row instead of a dead spinner. + expect(states).toStrictEqual([1, 2]) }), { git: true, diff --git a/packages/cli/test/session/retry-density-invalid-baseurl.test.ts b/packages/cli/test/session/retry-density-invalid-baseurl.test.ts index e247adfc56..18747cb62b 100644 --- a/packages/cli/test/session/retry-density-invalid-baseurl.test.ts +++ b/packages/cli/test/session/retry-density-invalid-baseurl.test.ts @@ -275,10 +275,13 @@ describe("retry density instrumentation (upstream transport failure)", () => { expect(upstream.hits[i]!.t - upstream.hits[i - 1]!.t).toBeGreaterThanOrEqual(waits[i - 1]! - 2) } } - expect(states).toHaveLength(phase === "request" ? 0 : 6) + // Request-phase ladders now publish session.status{retry} so Desktop can + // show the reconnect row instead of a dead spinner while the persistent + // server/network budget waits. Stream-phase ownership is unchanged. + expect(states).toHaveLength(6) for (const [i, state] of states.entries()) { expect(state.status).toMatchObject({ - attempt: i + 1, phaseAttempt: i + 1, phase: "stream", scope: "live-step", + attempt: i + 1, phaseAttempt: i + 1, phase, scope: phase === "request" ? "request" : "live-step", message: retries[i]!.reason, }) expect(state.status.next - upstream.hits[i]!.t).toBeGreaterThanOrEqual(waits[i]!) diff --git a/packages/cli/test/session/retry-status-density-no-maxmode.test.ts b/packages/cli/test/session/retry-status-density-no-maxmode.test.ts index 7fb4326262..289bc1c90a 100644 --- a/packages/cli/test/session/retry-status-density-no-maxmode.test.ts +++ b/packages/cli/test/session/retry-status-density-no-maxmode.test.ts @@ -6,11 +6,12 @@ import { SessionRetry } from "../../src/session/retry" * aligned to persistent budgetFor (network / rate_limit / server ignore phase). * * Field measurement (invalid baseURL, pre-ownership-fix): 20 session.status frames / ~32s. - * Pattern: request×4 + stream, repeated. After the ownership fix, request-phase does not - * publish session.status; only processor stream frames appear. + * Pattern: request×4 + stream, repeated. After persistent budgetFor, recoverable kinds + * keep the kind's exponential ceiling even when phase=request (no 4×/30s nesting). * - * After persistent budgetFor, recoverable kinds also stop falling into the 4×/30s request - * ladder — they keep the kind's exponential ceiling even when phase=request. + * Request-phase now also publishes session.status{retry} — the request ladder is often + * the only waiter the user observes (processor stream retries never see the failure), + * so Desktop must get the reconnect row instead of a dead spinner. */ describe("retry status publish density without maxMode", () => { @@ -41,7 +42,8 @@ describe("retry status publish density without maxMode", () => { // Pre-fix field ceiling used request×4 + stream packing (~20). Post-persistent // budgetFor, request-phase network no longer multiplies outer cycles by 4 short - // request retries; stream-side ownership remains the only session.status publisher. + // request retries; both request and stream ladders publish session.status{retry} + // at the kind's own exponential ceiling. // Keep the historical 15-note for the old nested-request arithmetic. const legacyRequestRetries = 4 const legacyPerCycle = legacyRequestRetries + 1 diff --git a/packages/cli/test/session/status-retry-map.test.ts b/packages/cli/test/session/status-retry-map.test.ts new file mode 100644 index 0000000000..77fadeb9f5 --- /dev/null +++ b/packages/cli/test/session/status-retry-map.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { SessionStatus } from "../../src/session/status" +import { SessionID } from "../../src/session/schema" +import { Instance } from "@/project/instance" +import { AppRuntime } from "@/effect/app-runtime" + +describe("SessionStatus.setRetry is visible to GET /session/status (list)", () => { + test("setRetry updates the map that list() serves", async () => { + await using tmp = await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const sessionID = SessionID.make("ses_recover_retry_map") + const result = await AppRuntime.runPromise( + SessionStatus.Service.use((svc) => + Effect.gen(function* () { + yield* svc.set(sessionID, { type: "busy" }) + yield* svc.setRetry(sessionID, { + type: "retry", + attempt: 1, + phaseAttempt: 1, + message: "No available channel for model mopd-a-20step", + next: Date.now() + 100, + phase: "request", + scope: "request", + }) + const map = yield* svc.list() + return map.get(sessionID) + }), + ), + ) + expect(result).toMatchObject({ + type: "retry", + attempt: 1, + message: "No available channel for model mopd-a-20step", + }) + }, + }) + }) +}) From 85c85e08430a691c28ec0096efc8193888c7915e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Wed, 14 Oct 2026 03:09:40 +0800 Subject: [PATCH 13/15] test(session): drop invalid await using on Instance.provide --- packages/cli/test/session/status-retry-map.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/session/status-retry-map.test.ts b/packages/cli/test/session/status-retry-map.test.ts index 77fadeb9f5..0ffea9f2ff 100644 --- a/packages/cli/test/session/status-retry-map.test.ts +++ b/packages/cli/test/session/status-retry-map.test.ts @@ -7,7 +7,7 @@ import { AppRuntime } from "@/effect/app-runtime" describe("SessionStatus.setRetry is visible to GET /session/status (list)", () => { test("setRetry updates the map that list() serves", async () => { - await using tmp = await Instance.provide({ + await Instance.provide({ directory: process.cwd(), fn: async () => { const sessionID = SessionID.make("ses_recover_retry_map") From 8f00f55f82493b15120f3f9a5ad0f7123586f01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Wed, 14 Oct 2026 03:31:25 +0800 Subject: [PATCH 14/15] test(session): request-phase retries surface as SessionStatus retry The density test still expected busy during request ladders; that is the old ownership. Request-phase now writes session.status{retry} so Desktop can show the reconnect row. --- .../cli/test/session/retry-density-invalid-baseurl.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/session/retry-density-invalid-baseurl.test.ts b/packages/cli/test/session/retry-density-invalid-baseurl.test.ts index 18747cb62b..733d79ec6c 100644 --- a/packages/cli/test/session/retry-density-invalid-baseurl.test.ts +++ b/packages/cli/test/session/retry-density-invalid-baseurl.test.ts @@ -237,7 +237,9 @@ describe("retry density instrumentation (upstream transport failure)", () => { yield* Deferred.await(observed).pipe(Effect.timeout("32 seconds")) expect(handle.message.error).toBeUndefined() - expect((yield* status.get(chat.id)).type).toBe(phase === "request" ? "busy" : "retry") + // Request-phase ladders write SessionStatus too (reconnect row), so both + // phases surface retry while the persistent network budget is waiting. + expect((yield* status.get(chat.id)).type).toBe("retry") yield* Fiber.interrupt(run) const exit = yield* Fiber.await(run) expect(Exit.isFailure(exit)).toBe(true) From 783382f9a090de7171c7d1924b7974df6a6cb0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B7=87=E8=90=A5?= Date: Wed, 14 Oct 2026 03:50:39 +0800 Subject: [PATCH 15/15] test(session): disable MCP tool-search deferral in after-hook settlement test The scripted model calls example_probe directly; tool-search deferral rejects it before tool.execute.after, so the settlement hook never runs. --- packages/cli/test/session/tool-gate-cancel.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/test/session/tool-gate-cancel.test.ts b/packages/cli/test/session/tool-gate-cancel.test.ts index 4bdeee03b3..de0a9fe934 100644 --- a/packages/cli/test/session/tool-gate-cancel.test.ts +++ b/packages/cli/test/session/tool-gate-cancel.test.ts @@ -309,6 +309,14 @@ function settlementConfig(origin: string): Partial { // [TP-SR-R21-32] for (const isError of [false, true]) { it.live(`Stop in MCP after hook retains the actual result and attachments (isError=${isError})`, () => Effect.gen(function* () { + // The scripted model calls the MCP tool directly; tool-search deferral would + // reject it before tool.execute.after runs. + const previousSearch = process.env.MIMOCODE_MCP_TOOL_SEARCH + process.env.MIMOCODE_MCP_TOOL_SEARCH = "false" + yield* Effect.addFinalizer(() => Effect.sync(() => { + if (previousSearch === undefined) delete process.env.MIMOCODE_MCP_TOOL_SEARCH + else process.env.MIMOCODE_MCP_TOOL_SEARCH = previousSearch + })) const mcp = new McpServer({ name: "settlement", version: "1" }) let calls = 0 mcp.registerTool("probe", { description: "Return an actual MCP observation", inputSchema: {} }, async () => {