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 fc9fa0b96a8c..9e13816a3c64 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1224,7 +1224,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 }) }) @@ -1601,6 +1629,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..5a08bd22143b 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2314,3 +2314,48 @@ 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") + 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) + }), + { + config: { + ...cfg, + command: { + "noreply-test": { + template: "NoReply test: $ARGUMENTS", + noReply: true, + }, + }, + }, + }, +)