From 3a3347e2b1693d54742f4fda6bbeb0599f908ce0 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 08:46:58 +0800 Subject: [PATCH 1/3] chore(specgit): bind delivery to issue 409 --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 09e2d450b..e09368185 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: headless-init-does +delivery: issue409 context: kind: branch - branch: fix/404-headless-init-does + branch: feat/409-issue409 issues: - - 404 -pr: 405 + - 409 From 27122277f89c7c5d7f4fd195724e41d5393d573c Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 08:47:22 +0800 Subject: [PATCH 2/3] chore: record delivery binding for issue409 --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index e09368185..938528e25 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/409-issue409 issues: - 409 +pr: 411 From 0e53c657921736052ab16cf975386bf652e8cb26 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 09:03:19 +0800 Subject: [PATCH 3/3] fix(session): complete run-mode turns for early-return commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Early-return command dispatches (/memory, /trust, /goal non-kick) wrote their parts and returned without ever entering the session runner, so the busy→idle status transition never fired and the run CLI — which exits its event loop only on the idle event — hung forever (#409). The response parts also lacked time.end, which run mode requires before printing text. Run the early-return writes as a micro-turn via SessionRunState.startIfIdle (idle sessions get the busy→idle transition; busy sessions keep today's inline semantics so the /goal busy guards still read true status), and stamp time {start,end} on the response parts. Goal kick dispatch stays outside the turn: its guards read the live session status. Regression: test/cli/run/early-return-command.test.ts (4 arms, all exit 0 with non-empty output). --- packages/opencode/src/session/prompt.ts | 301 +++++++++++------- .../test/cli/run/early-return-command.test.ts | 90 ++++++ 2 files changed, 278 insertions(+), 113 deletions(-) create mode 100644 packages/opencode/test/cli/run/early-return-command.test.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ea7aa6db4..2ae47bb92 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1878,6 +1878,16 @@ export const layer = Layer.effect( return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready) }) + // #409: early-return command dispatches (/memory, /trust, /goal non-kick) + // must still drive the busy→idle status transition — the run CLI exits its + // event loop on the idle event, which only the runner's onIdle publishes. + // startIfIdle keeps today's inline semantics while another turn is running + // (no queueing, no second idle); the in-flight turn re-emits idle itself. + const commandTurn = Effect.fnUntraced(function* (sessionID: SessionID, work: Effect.Effect) { + const handle = yield* state.startIfIdle(sessionID, lastAssistant(sessionID), work) + return yield* Option.getOrElse(handle, () => work) + }) + const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { yield* Effect.logInfo("command", { "session.id": input.sessionID, @@ -1885,87 +1895,107 @@ export const layer = Layer.effect( agent: input.agent, }) if (input.command === "memory") { - const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service)) - const argument = input.arguments.trim() - // #396: anything that is not an exact on/off is a status query — - // report the true state instead of a hardcoded "remains off". - const result = memory - ? argument === "on" - ? yield* memory.setEnabled(true) - : argument === "off" - ? yield* memory.setEnabled(false) - : yield* memory.status() - : "Memory remains off" - const model = yield* currentModel(input.sessionID) - const agentName = input.agent ?? (yield* agents.defaultAgent()) - const userMsg: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: agentName, - model: { providerID: model.providerID, modelID: model.modelID }, - } - yield* sessions.updateMessage(userMsg) - const commandPart: SessionV1.TextPart = { - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - type: "text", - text: `/memory ${input.arguments}`.trim(), - } - yield* sessions.updatePart(commandPart) - const responsePart: SessionV1.TextPart = { - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - type: "text", - text: result, - } - yield* sessions.updatePart(responsePart) - yield* sessions.touch(input.sessionID) - return { info: userMsg, parts: [commandPart, responsePart] } + return yield* commandTurn( + input.sessionID, + Effect.gen(function* () { + const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service)) + const argument = input.arguments.trim() + // #396: anything that is not an exact on/off is a status query — + // report the true state instead of a hardcoded "remains off". + const result = memory + ? argument === "on" + ? yield* memory.setEnabled(true) + : argument === "off" + ? yield* memory.setEnabled(false) + : yield* memory.status() + : "Memory remains off" + const model = yield* currentModel(input.sessionID) + const agentName = input.agent ?? (yield* agents.defaultAgent()) + const userMsg: SessionV1.User = { + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: agentName, + model: { providerID: model.providerID, modelID: model.modelID }, + } + yield* sessions.updateMessage(userMsg) + const commandPart: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: `/memory ${input.arguments}`.trim(), + } + yield* sessions.updatePart(commandPart) + const now = Date.now() + // time.end set so run-mode consumers print the response (run CLI + // only renders text parts once ended). + const responsePart: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: result, + time: { start: now, end: now }, + } + yield* sessions.updatePart(responsePart) + yield* sessions.touch(input.sessionID) + return { info: userMsg, parts: [commandPart, responsePart] } + }), + ) } // /trust command dispatch — early return BEFORE command registry lookup. // Trust writes are security-sensitive and MUST NOT be delegated to the // LLM-driven command template path; mirror /goal's early-return dispatch // (prompt.ts only wires + renders; the domain logic lives in workspace-trust.ts). if (input.command === "trust") { - const ctx = yield* InstanceState.context - const result = dispatchTrust(ctx.directory, input.arguments, ctx.worktree) - const m = yield* currentModel(input.sessionID) - const agentName = input.agent ?? (yield* agents.defaultAgent()) - const userMsg: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: agentName, - model: { providerID: m.providerID, modelID: m.modelID }, - } - yield* sessions.updateMessage(userMsg) - const cmdText: SessionV1.TextPart = { - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - type: "text", - text: `/trust ${input.arguments}`.trim(), - } - yield* sessions.updatePart(cmdText) - const responsePart: SessionV1.TextPart = { - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - type: "text", - text: result.text, - } - yield* sessions.updatePart(responsePart) - yield* sessions.touch(input.sessionID) - return { info: userMsg, parts: [cmdText, responsePart] } + return yield* commandTurn( + input.sessionID, + Effect.gen(function* () { + const ctx = yield* InstanceState.context + const result = dispatchTrust(ctx.directory, input.arguments, ctx.worktree) + const m = yield* currentModel(input.sessionID) + const agentName = input.agent ?? (yield* agents.defaultAgent()) + const userMsg: SessionV1.User = { + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: agentName, + model: { providerID: m.providerID, modelID: m.modelID }, + } + yield* sessions.updateMessage(userMsg) + const cmdText: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: `/trust ${input.arguments}`.trim(), + } + yield* sessions.updatePart(cmdText) + const now = Date.now() + // time.end set so run-mode consumers print the response (run CLI + // only renders text parts once ended). + const responsePart: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: result.text, + time: { start: now, end: now }, + } + yield* sessions.updatePart(responsePart) + yield* sessions.touch(input.sessionID) + return { info: userMsg, parts: [cmdText, responsePart] } + }), + ) } // Goal/Subgoal command dispatch — early return BEFORE command registry lookup if (goal && (input.command === "goal" || input.command === "subgoal")) { const dispatch = input.command === "goal" ? goal.dispatch : goal.dispatchSubgoal + // Dispatch runs OUTSIDE any runner turn: its busy guards read the true + // session status, which a commandTurn busy marker would corrupt. const dispatchResult = yield* dispatch(input.sessionID, input.arguments).pipe( Effect.catchCause((cause) => Effect.gen(function* () { @@ -1974,53 +2004,39 @@ export const layer = Layer.effect( }), ), ) - const m = yield* currentModel(input.sessionID) - const agentName = input.agent ?? (yield* agents.defaultAgent()) - const userMsg: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: agentName, - model: { providerID: m.providerID, modelID: m.modelID }, - } - yield* sessions.updateMessage(userMsg) - if (!dispatchResult) { - // Dispatch failed — return error message to user instead of silent fallthrough - const errorPart: SessionV1.TextPart = { + if (dispatchResult?.type === "kick" && input.command === "goal") { + const m = yield* currentModel(input.sessionID) + const agentName = input.agent ?? (yield* agents.defaultAgent()) + const userMsg: SessionV1.User = { + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: agentName, + model: { providerID: m.providerID, modelID: m.modelID }, + } + yield* sessions.updateMessage(userMsg) + const dispatchText = dispatchResult.announce ?? dispatchResult.text + const cmdText: SessionV1.TextPart = { id: PartID.ascending(), messageID: userMsg.id, sessionID: input.sessionID, type: "text", - text: `⚠️ /${input.command} 执行失败,请检查日志。`, - synthetic: true, + text: `/${input.command} ${input.arguments}`.trim(), } - yield* sessions.updatePart(errorPart) + yield* sessions.updatePart(cmdText) + // Non-synthetic so UserMessage renders it — the command confirmation + // (e.g. "⏸ 目标已暂停") must be visible. Matches the goal "done" case + // (loop.ts), which emits visible goal messages as non-synthetic parts. + const responsePart: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: dispatchText, + } + yield* sessions.updatePart(responsePart) yield* sessions.touch(input.sessionID) - return { info: userMsg, parts: [errorPart] } - } - const dispatchText = dispatchResult.announce ?? dispatchResult.text - const cmdText: SessionV1.TextPart = { - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - type: "text", - text: `/${input.command} ${input.arguments}`.trim(), - } - yield* sessions.updatePart(cmdText) - // Non-synthetic so UserMessage renders it — the command confirmation - // (e.g. "⏸ 目标已暂停") must be visible. Matches the goal "done" case - // (loop.ts), which emits visible goal messages as non-synthetic parts. - const responsePart: SessionV1.TextPart = { - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - type: "text", - text: dispatchText, - } - yield* sessions.updatePart(responsePart) - yield* sessions.touch(input.sessionID) - if (dispatchResult.type === "kick" && input.command === "goal") { // GOAL-TURN-SCOPE: this loop() is a goal-driven turn (kick or // resume-kick) — mark it so the step ceiling applies and ESC maps to // a goal pause. @@ -2041,7 +2057,66 @@ export const layer = Layer.effect( } return yield* loop({ sessionID: input.sessionID }) } - return { info: userMsg, parts: [cmdText, responsePart] } + return yield* commandTurn( + input.sessionID, + Effect.gen(function* () { + const m = yield* currentModel(input.sessionID) + const agentName = input.agent ?? (yield* agents.defaultAgent()) + const userMsg: SessionV1.User = { + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: agentName, + model: { providerID: m.providerID, modelID: m.modelID }, + } + yield* sessions.updateMessage(userMsg) + if (!dispatchResult) { + // Dispatch failed — return error message to user instead of silent fallthrough + const now = Date.now() + // time.end set so run-mode consumers print the response (run CLI + // only renders text parts once ended). + const errorPart: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: `⚠️ /${input.command} 执行失败,请检查日志。`, + synthetic: true, + time: { start: now, end: now }, + } + yield* sessions.updatePart(errorPart) + yield* sessions.touch(input.sessionID) + return { info: userMsg, parts: [errorPart] } + } + const dispatchText = dispatchResult.announce ?? dispatchResult.text + const cmdText: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: `/${input.command} ${input.arguments}`.trim(), + } + yield* sessions.updatePart(cmdText) + // Non-synthetic so UserMessage renders it — the command confirmation + // (e.g. "⏸ 目标已暂停") must be visible. Matches the goal "done" case + // (loop.ts), which emits visible goal messages as non-synthetic parts. + const now = Date.now() + // time.end set so run-mode consumers print the response (run CLI + // only renders text parts once ended). + const responsePart: SessionV1.TextPart = { + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + text: dispatchText, + time: { start: now, end: now }, + } + yield* sessions.updatePart(responsePart) + yield* sessions.touch(input.sessionID) + return { info: userMsg, parts: [cmdText, responsePart] } + }), + ) } const cmd = yield* commands.get(input.command) diff --git a/packages/opencode/test/cli/run/early-return-command.test.ts b/packages/opencode/test/cli/run/early-return-command.test.ts new file mode 100644 index 000000000..22ede5489 --- /dev/null +++ b/packages/opencode/test/cli/run/early-return-command.test.ts @@ -0,0 +1,90 @@ +// Regression test for #409: run mode hangs forever on early-return commands. +// +// Early-return command dispatches (/memory, /trust, /goal non-kick at +// src/session/prompt.ts) write a user message + text parts and return without +// an assistant step. The run CLI exits its event loop only on the +// `session.status {type:"idle"}` event, which the runner publishes on the +// busy→idle transition — a transition these commands never made, so +// `opencode run "/memory"` (and `run --command memory`) waited forever. +// +// Secondary bug under test: run.ts prints text parts only when `time.end` is +// set; the early-return response parts carried no `time`, so even a fixed +// hang would print nothing. +// +// Fix under test: the early-return branches run as a micro-turn through +// SessionRunState.startIfIdle when the session is idle (busy sessions keep +// today's inline semantics), and their response parts carry +// `time: { start, end }`. +// +// Harness notes (mirrors test/cli/run/headless-init.test.ts): +// - run.ts resolves its directory from process.env.PWD, so arms pin PWD to +// the fixture home. +// - OPENCODE_DB="" restores the file-backed DB under the isolated home. +// - Arms stub one LLM reply only so the CLI has a default model to resolve; +// the early-return paths themselves never call it. +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" + +const RUN_ENV = { OPENCODE_DB: "" } + +const expectCompleted = (result: { exitCode: number; stdout: string }) => { + expect(result.exitCode).toBe(0) + expect(result.stdout.trim().length).toBeGreaterThan(0) +} + +describe("run mode early-return commands complete (#409)", () => { + // The #409 repro: "/memory" as a plain text prompt through the run CLI. + // RED before the fix (hangs until the spawn timeout kills it). + cliIt.live( + 'opencode run "/memory" prints the status and exits', + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* llm.text("unused") + const result = yield* opencode.run("/memory", { env: { PWD: home, ...RUN_ENV }, timeoutMs: 30_000 }) + expectCompleted(result) + }), + 120_000, + ) + + // The --command flag arm from the issue evidence (v1.0.29 hangs the same way). + cliIt.live( + "opencode run --command memory prints the status and exits", + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* llm.text("unused") + const result = yield* opencode.run("", { command: "memory", env: { PWD: home, ...RUN_ENV }, timeoutMs: 30_000 }) + expectCompleted(result) + }), + 120_000, + ) + + // /trust shares the early-return shape; it is not in the command registry, + // so the text path cannot reach it — verify via --command (as the issue did). + cliIt.live( + "opencode run --command trust prints the trust status and exits", + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* llm.text("unused") + const result = yield* opencode.run("status", { + command: "trust", + env: { PWD: home, ...RUN_ENV }, + timeoutMs: 30_000, + }) + expectCompleted(result) + }), + 120_000, + ) + + // /goal with no arguments takes the non-kick early-return shape (status line). + cliIt.live( + 'opencode run "/goal" prints the goal status and exits', + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* llm.text("unused") + const result = yield* opencode.run("/goal", { env: { PWD: home, ...RUN_ENV }, timeoutMs: 30_000 }) + expectCompleted(result) + }), + 120_000, + ) +})