fix(session): restore compact slash arguments

This commit is contained in:
Shoubhit Dash 2026-05-21 15:33:40 +05:30
parent f5d20c580b
commit fbce48f597
8 changed files with 160 additions and 10 deletions

View file

@ -18,6 +18,7 @@ const optimistic: Array<{
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
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)
})
})

View file

@ -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())

View file

@ -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) {

View file

@ -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"]))

View file

@ -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

View file

@ -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<void>
}
@ -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, {

View file

@ -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<Schema.Schema.Type<typeof CompactionPart>>

View file

@ -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",
() => {