From 076ca964505e4a6e75c9942feaa2d733ea4ecba7 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 22 May 2026 12:11:27 +0800 Subject: [PATCH 1/2] feat(command): add noReply flag to skip LLM dispatch and display parts directly When a command is configured with `noReply: true`, the LLM round-trip is skipped entirely. The hook-modified parts are written as a completed assistant message so they render immediately in the TUI without any token cost or latency. This enables plugins like opencode-review to register instant toggle commands (e.g. `/review:auto on/off`) that execute purely locally via the `command.execute.before` hook. Closes #28292 Co-Authored-By: Claude Opus 4.7 --- packages/opencode/src/command/index.ts | 2 + packages/opencode/src/config/command.ts | 1 + packages/opencode/src/session/prompt.ts | 31 ++++++++++++- packages/opencode/test/session/prompt.test.ts | 43 +++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 96e171733df0..0c4db8f73ee1 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -35,6 +35,7 @@ export const Info = Schema.Struct({ // Some command templates are lazy promises from MCP prompt resolution. template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), + noReply: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), }).annotate({ identifier: "Command" }) @@ -105,6 +106,7 @@ export const layer = Layer.effect( return command.template }, subtask: command.subtask, + noReply: command.noReply, hints: hints(command.template), } } diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index d5046b6a1756..9fb16ed56794 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -17,6 +17,7 @@ export const Info = Schema.Struct({ agent: Schema.optional(Schema.String), model: Schema.optional(ConfigModelID), subtask: Schema.optional(Schema.Boolean), + noReply: Schema.optional(Schema.Boolean), }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e39d0016ab74..fb170072486c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1225,7 +1225,35 @@ export const layer = Layer.effect( yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) } - if (input.noReply === true) return message + if (input.noReply === true) { + const ctx = yield* InstanceState.context + const assistantMsg: MessageV2.Assistant = { + id: MessageID.ascending(), + parentID: message.info.id, + role: "assistant", + mode: message.info.agent, + agent: message.info.agent, + variant: message.info.model.variant, + path: { cwd: ctx.directory, root: ctx.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: message.info.model.modelID, + providerID: message.info.model.providerID, + time: { created: Date.now(), completed: Date.now() }, + sessionID: input.sessionID, + } + yield* sessions.updateMessage(assistantMsg) + + for (const part of input.parts) { + yield* sessions.updatePart({ + ...part, + id: PartID.ascending(), + messageID: assistantMsg.id, + sessionID: input.sessionID, + } as MessageV2.Part) + } + return message + } return yield* loop({ sessionID: input.sessionID }) }) @@ -1602,6 +1630,7 @@ export const layer = Layer.effect( agent: userAgent, parts, variant: input.variant, + noReply: cmd.noReply ?? false, }) yield* bus.publish(Command.Event.Executed, { name: input.command, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ff9ded4d1927..ce23f44cae1a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2314,3 +2314,46 @@ noLLMServer.instance( }), 30_000, ) + +// noReply command semantics + +noLLMServer.instance( + "noReply command skips LLM and creates assistant message with parts", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const result = yield* prompt.command({ + sessionID: session.id, + command: "noreply-test", + arguments: "hello", + }) + + // command() returns the user message + expect(result.info.role).toBe("user") + + // check that an assistant message was created with the template text + const messages = yield* sessions.messages({ sessionID: session.id }) + const assistants = messages.filter((msg) => msg.info.role === "assistant") + expect(assistants.length).toBe(1) + const assistant = assistants[0] + expect(assistant.info.role).toBe("assistant") + expect(assistant.info.time.completed).toBeDefined() + const textParts = assistant.parts.filter((p) => p.type === "text") + expect(textParts.length).toBeGreaterThan(0) + expect(textParts.some((p) => p.type === "text" && p.text.includes("hello"))).toBe(true) + }), + { + config: { + ...cfg, + command: { + "noreply-test": { + template: "NoReply test: $ARGUMENTS", + noReply: true, + }, + }, + }, + }, +) From 32eaf86b352f8cd755d001edd39b4d7fd3688f39 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 22 May 2026 12:12:07 +0800 Subject: [PATCH 2/2] fix: add type guard for assistant time.completed in test Co-Authored-By: Claude Opus 4.7 --- packages/opencode/test/session/prompt.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ce23f44cae1a..5a08bd22143b 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2340,7 +2340,9 @@ noLLMServer.instance( expect(assistants.length).toBe(1) const assistant = assistants[0] expect(assistant.info.role).toBe("assistant") - expect(assistant.info.time.completed).toBeDefined() + if (assistant.info.role === "assistant") { + expect(assistant.info.time.completed).toBeDefined() + } const textParts = assistant.parts.filter((p) => p.type === "text") expect(textParts.length).toBeGreaterThan(0) expect(textParts.some((p) => p.type === "text" && p.text.includes("hello"))).toBe(true)