diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 83b6212dcc..d99516e591 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -18,6 +18,7 @@ const optimistic: Array<{ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] +const sentSummaries: Array<{ sessionID: string; instructions?: string }> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] @@ -25,7 +26,7 @@ let params: { id?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined -const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const clientFor = (directory: string) => { createdClients.push(directory) @@ -47,6 +48,10 @@ const clientFor = (directory: string) => { prompt: async () => ({ data: undefined }), promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), + summarize: async (input: { sessionID: string; instructions?: string }) => { + sentSummaries.push({ sessionID: input.sessionID, instructions: input.instructions }) + return { data: undefined } + }, abort: async () => ({ data: undefined }), }, worktree: { @@ -208,6 +213,8 @@ beforeEach(() => { optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] + sentSummaries.length = 0 params = {} sentShell.length = 0 syncedDirectories.length = 0 @@ -342,4 +349,31 @@ describe("prompt submit worktree selection", () => { expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) + + test("submits typed compact slash arguments as summarize instructions", async () => { + params = { id: "session-1" } + promptValue = [{ type: "text", content: "/compact keep unresolved TODOs", start: 0, end: 30 }] + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(sentSummaries).toEqual([{ sessionID: "session-1", instructions: "keep unresolved TODOs" }]) + expect(optimistic).toHaveLength(0) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05f0a3ed2c..d127fccbe0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -50,6 +50,21 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") +const slashCommand = (text: string) => { + if (!text.startsWith("/")) return undefined + return text.split("\n")[0].split(" ")[0].slice(1) +} + +const slashArguments = (text: string) => { + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") +} + +const isCompactSlash = (command: string | undefined) => command === "compact" || command === "summarize" + export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) @@ -71,8 +86,27 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return true } - const [head, ...tail] = text.split(" ") - const cmd = head?.startsWith("/") ? head.slice(1) : undefined + const cmd = slashCommand(text) + if (isCompactSlash(cmd)) { + setBusy() + try { + if (!(await wait())) { + setIdle() + return false + } + + const instructions = slashArguments(text).trim() + const payload = instructions + ? { sessionID: input.draft.sessionID, ...input.draft.model, instructions } + : { sessionID: input.draft.sessionID, ...input.draft.model } + await input.client.session.summarize(payload) + return true + } catch (err) { + setIdle() + throw err + } + } + if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { setBusy() try { @@ -84,7 +118,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { await input.client.session.command({ sessionID: input.draft.sessionID, command: cmd, - arguments: tail.join(" "), + arguments: slashArguments(text), agent: input.draft.agent, model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, variant: input.draft.variant, @@ -453,8 +487,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) + const commandName = slashCommand(text) ?? "" const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() @@ -462,7 +495,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .command({ sessionID: session.id, command: commandName, - arguments: args.join(" "), + arguments: slashArguments(text), agent, model: `${model.providerID}/${model.modelID}`, variant, @@ -483,6 +516,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) return } + + if (isCompactSlash(commandName)) { + clearInput() + const instructions = slashArguments(text).trim() + const payload = instructions + ? { sessionID: session.id, ...model, instructions } + : { sessionID: session.id, ...model } + client.session.summarize(payload).catch((err) => { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } } const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f4b11fa465..b7f911ecbd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1143,6 +1143,21 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if (isCompactSlash(inputText)) { + const instructions = slashArguments(inputText).trim() + const payload = instructions + ? { + sessionID, + modelID: selectedModel.modelID, + providerID: selectedModel.providerID, + instructions, + } + : { + sessionID, + modelID: selectedModel.modelID, + providerID: selectedModel.providerID, + } + void sdk.client.session.summarize(payload) } else if ( inputText.startsWith("/") && iife(() => { @@ -1220,6 +1235,19 @@ export function Prompt(props: PromptProps) { input.clear() return true } + + function isCompactSlash(text: string) { + const command = text.split("\n")[0].split(" ")[0].slice(1) + return text.startsWith("/") && (command === "compact" || command === "summarize") + } + + function slashArguments(text: string) { + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + } const exit = useExit() function pasteText(text: string, virtualText: string) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index b8c8a142be..314aaba6b2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -62,6 +62,7 @@ export const SummarizePayload = Schema.Struct({ providerID: ProviderID, modelID: ModelID, auto: Schema.optional(Schema.Boolean), + instructions: Schema.optional(Schema.String), }) export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 2e3617d146..8096dcb470 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -277,6 +277,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", modelID: ctx.payload.modelID, }, auto: ctx.payload.auto ?? false, + instructions: ctx.payload.instructions, }) yield* promptSvc.loop({ sessionID: ctx.params.sessionID }) return true diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ef007fe74d..ae5382e7b9 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -133,6 +133,12 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) { return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") } +function withInstructions(prompt: string, instructions: string | undefined) { + const trimmed = instructions?.trim() + if (!trimmed) return prompt + return [prompt, "Additional user instructions for this compaction:", trimmed].join("\n\n") +} + function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -202,6 +208,7 @@ export interface Interface { model: { providerID: ProviderID; modelID: ModelID } auto: boolean overflow?: boolean + instructions?: string }) => Effect.Effect } @@ -400,7 +407,10 @@ export const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const nextPrompt = withInstructions( + compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }), + compactionPart?.instructions, + ) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -587,6 +597,7 @@ export const layer = Layer.effect( model: { providerID: ProviderID; modelID: ModelID } auto: boolean overflow?: boolean + instructions?: string }) { const msg = yield* session.updateMessage({ id: MessageID.ascending(), @@ -603,6 +614,7 @@ export const layer = Layer.effect( type: "compaction", auto: input.auto, overflow: input.overflow, + instructions: input.instructions, }) if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2745ff4f45..827d761967 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -186,6 +186,7 @@ export const CompactionPart = Schema.Struct({ type: Schema.Literal("compaction"), auto: Schema.Boolean, overflow: Schema.optional(Schema.Boolean), + instructions: Schema.optional(Schema.String), tail_start_id: Schema.optional(MessageID), }).annotate({ identifier: "CompactionPart" }) export type CompactionPart = Types.DeepMutable> diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 55ddc621ca..310712df4a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -287,8 +287,8 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { ) } -function createSummaryCompaction(sessionID: SessionID) { - return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) +function createSummaryCompaction(sessionID: SessionID, instructions?: string) { + return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false, instructions }) } function readCompactionPart(sessionID: SessionID) { @@ -959,6 +959,30 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) + itCompaction.instance( + "appends stored instructions to compaction prompt", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createSummaryCompaction(session.id, "focus on unresolved TODOs") + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toContain("Additional user instructions for this compaction") + expect(captured).toContain("focus on unresolved TODOs") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + itCompaction.instance( "falls back to full summary when even one recent turn exceeds preserve token budget", () => {