From f9ff3ae8c3b45e818de950b2b52ed658ede47772 Mon Sep 17 00:00:00 2001 From: Mulyawan Sentosa Date: Thu, 24 Sep 2026 09:58:14 +0700 Subject: [PATCH 1/2] feat: inject user guidance into the running turn While a turn was running, Enter was rejected with "wait for the current response or press esc to interrupt", so sending a follow-up instruction meant waiting for the whole turn or interrupting the agent. A message queued until the end of the turn would still arrive too late to override instructions the agent had already executed. SessionManager now keeps a small per-session queue of supplemental prompts. Before every LLM call of the running turn the queue is drained: each entry becomes a user message (including images and selected skills) appended to the conversation, so the model reads it together with the work it already did and can revise or supersede the earlier instructions. When a turn would end while guidance is still queued, the loop stays alive for one more iteration instead of deferring the guidance to a later turn. - session: addSupplementaryPrompt / cancelSupplementaryPrompt / listPendingSupplementaryPrompts / countPendingSupplementaryPrompts, the onSupplementaryQueueChanged and onSupplementaryPromptInjected callbacks, and MessageMeta.isSupplementary - session: drain the queue before each request, keep the loop alive while guidance is pending, and drop the queue when a session is cleaned up - cli: prompts typed while busy are handed to the session, the prompt area lists the guidance that is still waiting, Backspace on an empty prompt removes the last entry, and injected guidance is rendered as a labelled block (also in raw mode) - tests: 7 core tests covering the queue semantics and the request payloads of a turn that receives guidance mid-flight, plus 4 Ink tests for the input wiring - docs: README and quickstart key tables Refs #113, #117 --- README-en.md | 9 + README.md | 7 + docs/quickstart.md | 2 + docs/quickstart_en.md | 2 + .../cli/src/tests/prompt-input-queue.test.ts | 147 +++++++ packages/cli/src/tests/prompt-queue.test.ts | 21 + .../src/ui/components/MessageView/index.tsx | 23 +- .../src/ui/components/MessageView/utils.ts | 3 + packages/cli/src/ui/core/prompt-queue.ts | 34 ++ packages/cli/src/ui/index.ts | 1 + packages/cli/src/ui/views/App.tsx | 49 ++- packages/cli/src/ui/views/PromptInput.tsx | 79 +++- packages/core/src/index.ts | 3 +- packages/core/src/session.ts | 146 +++++++ .../src/tests/supplementary-prompts.test.ts | 372 ++++++++++++++++++ 15 files changed, 874 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/tests/prompt-input-queue.test.ts create mode 100644 packages/cli/src/tests/prompt-queue.test.ts create mode 100644 packages/cli/src/ui/core/prompt-queue.ts create mode 100644 packages/core/src/tests/supplementary-prompts.test.ts diff --git a/README-en.md b/README-en.md index 98f0134e..339973d8 100644 --- a/README-en.md +++ b/README-en.md @@ -88,11 +88,20 @@ Skills are discovered from these locations, in priority order: | Key | Action | |------------------|----------------------------------------------------------| | `Enter` | Send the prompt | +| `Enter` (busy) | Send extra guidance to the turn that is running | | `Shift+Enter` | Insert a newline (also `Ctrl+J`) | | `Ctrl+V` | Paste an image from the clipboard | | `Esc` | Interrupt the current model turn | +| `Backspace` | On an empty prompt: remove the last queued guidance | | `Ctrl+D` twice | Quit Deep Code | +While a turn is running, `Enter` no longer blocks and does not have to wait: the prompt becomes +supplemental guidance, appended to the conversation right before the model's next step of that +turn. The model therefore reads it together with the work it already did and can revise or +supersede the earlier instructions. Up to 10 messages can wait; press `Backspace` on an empty +prompt to drop the last one. `Esc` still interrupts the turn immediately, and slash commands +still wait for the turn to finish. + ## Supported Models - `deepseek-flash` (Recommended) diff --git a/README.md b/README.md index 75b91e26..3d509638 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,18 @@ Skills 会按以下优先级扫描: | 按键 | 操作 | |---------------|--------------------| | `Enter` | 发送消息 | +| `Enter`(忙碌时) | 作为补充指引发给正在执行的这一轮 | | `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | | `Ctrl+V` | 从剪贴板粘贴图片 | | `Esc` | 中断当前模型回复 | +| `Backspace` | 输入框为空时,移除最后一条待注入的指引 | | 连续 `Ctrl+D` | 退出 | +AI 正在回复时,`Enter` 不再被拒绝,也不必等到本轮结束:消息会作为补充指引,在本轮下一次 +LLM 调用前作为 user 消息追加到对话中。模型因此能读到它,并结合已完成的工作修改甚至推翻之前的 +指令。最多可排队 10 条;输入框为空时按 `Backspace` 可移除最后一条。按 `Esc` 仍会立即中断本轮, +斜杠命令仍需等待本轮结束。 + ## 支持的模型 - `deepseek-flash`(推荐使用) diff --git a/docs/quickstart.md b/docs/quickstart.md index 2bb9513a..3564b51e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -107,8 +107,10 @@ deepcode -p "总结这个项目" | 操作 | 用法 | | ---- | ---- | | 发送消息 | `Enter` | +| AI 回复中补充指令 | 输入后按 `Enter`,在下一次 LLM 调用前注入本轮 | | 输入多行 | `Shift+Enter` 或 `Ctrl+J` | | 中断当前回复 | `Esc` | +| 移除最后一条待注入指引 | 输入框为空时按 `Backspace` | | 粘贴图片 | `Ctrl+V` | | 退出 | 连续按两次 `Ctrl+D`,或使用 `/exit` | diff --git a/docs/quickstart_en.md b/docs/quickstart_en.md index 58adf09a..881c823d 100644 --- a/docs/quickstart_en.md +++ b/docs/quickstart_en.md @@ -107,8 +107,10 @@ Before editing files, propose a plan for adding pagination to the user list. | Action | Key | | ------ | --- | | Send message | `Enter` | +| Send guidance while the AI is responding | Type it and press `Enter`; it is injected before the model's next step | | Insert a newline | `Shift+Enter` or `Ctrl+J` | | Interrupt the current response | `Esc` | +| Remove the last queued guidance | Press `Backspace` on an empty prompt | | Paste an image | `Ctrl+V` | | Quit | Press `Ctrl+D` twice, or use `/exit` | diff --git a/packages/cli/src/tests/prompt-input-queue.test.ts b/packages/cli/src/tests/prompt-input-queue.test.ts new file mode 100644 index 00000000..8f1c1594 --- /dev/null +++ b/packages/cli/src/tests/prompt-input-queue.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { Readable, Writable } from "node:stream"; +import { test } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { stripVTControlCharacters } from "node:util"; +import React from "react"; +import { render } from "ink"; +import { PromptInput } from "../ui"; +import type { PromptSubmission } from "../ui"; + +type Harness = ReturnType; + +function createHarness() { + const frames: string[] = []; + const stdout = Object.assign( + new Writable({ + write(chunk, _encoding, callback) { + const frame = stripVTControlCharacters(chunk.toString()); + if (frame.trim()) { + frames.push(frame); + } + callback(); + }, + }), + { columns: 100, rows: 24, isTTY: true } + ); + const stdin = Object.assign(new Readable({ read() {} }), { + isTTY: true, + setRawMode() {}, + ref() {}, + unref() {}, + }); + return { frames, stdout, stdin }; +} + +function renderPromptInput( + harness: Harness, + props: { + busy: boolean; + queuedPrompts?: string[]; + onSubmit: (submission: PromptSubmission) => void; + onRemoveQueuedPrompt?: () => void; + } +) { + const element = React.createElement(PromptInput, { + projectRoot: process.cwd(), + skills: [], + modelConfig: { model: "deepseek-v4-flash", thinkingEnabled: true, reasoningEffort: "max" }, + screenWidth: 100, + promptHistory: [], + busy: props.busy, + queuedPrompts: props.queuedPrompts, + planMode: false, + onSubmit: props.onSubmit, + onModelConfigChange: () => "", + onPlanModeChange: () => {}, + onInterrupt: () => {}, + onRemoveQueuedPrompt: props.onRemoveQueuedPrompt, + }); + + return render(element, { + stdout: harness.stdout as unknown as NodeJS.WriteStream, + stdin: harness.stdin as unknown as NodeJS.ReadStream, + debug: true, + patchConsole: false, + exitOnCtrlC: false, + }); +} + +async function press(harness: Harness, app: { waitUntilRenderFlush: () => Promise }, data: string) { + harness.stdin.push(data); + await delay(0); + await app.waitUntilRenderFlush(); +} + +test("PromptInput submits a plain prompt on enter while idle", async () => { + const harness = createHarness(); + const submissions: PromptSubmission[] = []; + const app = renderPromptInput(harness, { busy: false, onSubmit: (submission) => submissions.push(submission) }); + try { + await press(harness, app, "first prompt"); + await press(harness, app, "\r"); + assert.deepEqual( + submissions.map((submission) => submission.text), + ["first prompt"] + ); + } finally { + app.unmount(); + } +}); + +test("PromptInput still submits a plain prompt while busy so App can queue it", async () => { + const harness = createHarness(); + const submissions: PromptSubmission[] = []; + const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) }); + try { + await press(harness, app, "queued prompt"); + await press(harness, app, "\r"); + assert.deepEqual( + submissions.map((submission) => submission.text), + ["queued prompt"] + ); + } finally { + app.unmount(); + } +}); + +test("PromptInput keeps blocking slash commands while busy", async () => { + const harness = createHarness(); + const submissions: PromptSubmission[] = []; + const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) }); + try { + await press(harness, app, "/model"); + await press(harness, app, "\r"); + assert.deepEqual(submissions, []); + // The buffer is kept so the user can run the command once the turn finishes. + assert.match(harness.frames.at(-1) ?? "", /\/model/); + } finally { + app.unmount(); + } +}); + +test("PromptInput renders pending guidance and removes the last one on backspace", async () => { + const harness = createHarness(); + let removed = 0; + const app = renderPromptInput(harness, { + busy: true, + queuedPrompts: ["older prompt", "newer prompt"], + onSubmit: () => {}, + onRemoveQueuedPrompt: () => { + removed += 1; + }, + }); + try { + await delay(0); + await app.waitUntilRenderFlush(); + const frame = harness.frames.at(-1) ?? ""; + assert.match(frame, /guidance 1\. older prompt/); + assert.match(frame, /guidance 2\. newer prompt/); + assert.match(frame, /2 guidance queued/); + + await press(harness, app, "\u007F"); + assert.equal(removed, 1); + } finally { + app.unmount(); + } +}); diff --git a/packages/cli/src/tests/prompt-queue.test.ts b/packages/cli/src/tests/prompt-queue.test.ts new file mode 100644 index 00000000..96860b20 --- /dev/null +++ b/packages/cli/src/tests/prompt-queue.test.ts @@ -0,0 +1,21 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { formatQueueHint, formatQueuedPromptPreview } from "../ui"; + +test("formatQueuedPromptPreview collapses whitespace and truncates long prompts", () => { + assert.equal(formatQueuedPromptPreview(" fix the\n\nfailing test "), "fix the failing test"); + assert.equal(formatQueuedPromptPreview("a".repeat(80), 0, 20), `${"a".repeat(19)}…`); + assert.equal(formatQueuedPromptPreview("", 0), ""); +}); + +test("formatQueuedPromptPreview falls back to the image count for image-only prompts", () => { + assert.equal(formatQueuedPromptPreview("", 1), "[1 image]"); + assert.equal(formatQueuedPromptPreview(" ", 2), "[2 images]"); +}); + +test("formatQueueHint describes how many prompts wait for the next step", () => { + assert.equal(formatQueueHint(0), ""); + assert.equal(formatQueueHint(1), "1 guidance queued · backspace to remove"); + assert.equal(formatQueueHint(3), "3 guidance queued · backspace to remove"); +}); diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx index 5479eb86..9f07eae6 100644 --- a/packages/cli/src/ui/components/MessageView/index.tsx +++ b/packages/cli/src/ui/components/MessageView/index.tsx @@ -24,13 +24,20 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (message.role === "user") { const content = message.content || "(no content)"; const text = message.meta?.isAnswers ? renderMarkdown(content) : content; - return ( - - ); + const attachmentCount = Array.isArray(message.contentParams) ? message.contentParams.length : 0; + + if (message.meta?.isSupplementary) { + return ( + + + + + + + ); + } + + return ; } if (message.role === "assistant") { @@ -169,7 +176,7 @@ function StatusLine({ params, width, }: { - bulletColor: "gray" | "green" | "red"; + bulletColor: "gray" | "green" | "red" | "yellow"; name: string; params: string; width: number; diff --git a/packages/cli/src/ui/components/MessageView/utils.ts b/packages/cli/src/ui/components/MessageView/utils.ts index 15d7e2f4..26fb6855 100644 --- a/packages/cli/src/ui/components/MessageView/utils.ts +++ b/packages/cli/src/ui/components/MessageView/utils.ts @@ -230,6 +230,9 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.role === "user") { const content = message.content || "(no content)"; const text = message.meta?.isAnswers ? renderMarkdown(content) : content; + if (message.meta?.isSupplementary) { + return `${chalk("✧")} ${chalk("Guidance")} ${chalk("sent while the turn was running")}\n${chalk(`> ${text}`)}`; + } return chalk(`> ${text}`); } diff --git a/packages/cli/src/ui/core/prompt-queue.ts b/packages/cli/src/ui/core/prompt-queue.ts new file mode 100644 index 00000000..162cca46 --- /dev/null +++ b/packages/cli/src/ui/core/prompt-queue.ts @@ -0,0 +1,34 @@ +/** + * Presentation helpers for the supplemental prompt queue. + * + * The queue itself lives in `SessionManager` (`addSupplementaryPrompt`, + * `cancelSupplementaryPrompt`, `listPendingSupplementaryPrompts`): each queued + * prompt is appended to the conversation as a user message right before the next + * LLM call of the running turn, so the model can revise its plan instead of + * waiting for the turn to end. + * + * This module only formats what the CLI shows for the prompts that are still + * waiting for that injection point. + */ + +/** + * Build the one-line label for a queued prompt. Whitespace is collapsed so + * multi-line prompts stay on a single row, and prompts made of image + * attachments only fall back to an image count. + */ +export function formatQueuedPromptPreview(text: string, imageCount = 0, maxLength = 60): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + const label = collapsed || (imageCount > 0 ? `[${imageCount} image${imageCount === 1 ? "" : "s"}]` : ""); + if (label.length <= maxLength) { + return label; + } + return `${label.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; +} + +/** Footer hint describing how many prompts are still waiting for the next step. */ +export function formatQueueHint(queuedCount: number): string { + if (queuedCount <= 0) { + return ""; + } + return `${queuedCount} guidance queued · backspace to remove`; +} diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts index ce9c53bc..bd9787d6 100644 --- a/packages/cli/src/ui/index.ts +++ b/packages/cli/src/ui/index.ts @@ -98,4 +98,5 @@ export { type FileMentionToken, } from "./core/file-mentions"; export { findExpandedThinkingId, isCollapsedThinking } from "./core/thinking-state"; +export { formatQueuedPromptPreview, formatQueueHint } from "./core/prompt-queue"; export { buildExitSummaryText, buildPluginRateLimitHintText, buildResumeHintText } from "./exit-summary"; diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 82f0eaa1..15f63d6f 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -20,6 +20,7 @@ import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, } from "../core/ask-user-question"; +import { formatQueuedPromptPreview } from "../core/prompt-queue"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; import { PlanImplementationPrompt, @@ -53,6 +54,7 @@ import type { SessionMessage, SessionStatus, SkillInfo, + SupplementaryPrompt, UndoTarget, UserPromptContent, } from "@vegamo/deepcode-core"; @@ -69,6 +71,16 @@ type AppProps = { onRestart?: () => void; }; +/** + * One-line label for guidance waiting to be injected into the running turn, so + * the user can recognise what will be sent at the model's next step. + */ +function buildQueuedPromptPreview(submission: { text: string; imageUrls: string[]; skills?: SkillInfo[] }): string { + const skillNames = submission.skills?.map((skill) => skill.name).filter(Boolean) ?? []; + const text = submission.text.trim() || (skillNames.length > 0 ? skillNames.join(", ") : ""); + return formatQueuedPromptPreview(text, submission.imageUrls.length); +} + function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); @@ -82,8 +94,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const writeRef = useRef(write); const lastRenderedColumnsRef = useRef(null); const messagesRef = useRef([]); + const busyRef = useRef(false); const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); + const [pendingSupplementary, setPendingSupplementary] = useState([]); const [skills, setSkills] = useState([]); const [messages, setMessages] = useState([]); const [sessions, setSessions] = useState([]); @@ -114,6 +128,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes rawModeRef.current = mode; messagesRef.current = messages; + busyRef.current = busy; const sessionManager = useMemo(() => { return new SessionManager({ @@ -149,6 +164,16 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 setMcpStatuses(sessionManager.getMcpStatus()); }, + onSupplementaryQueueChanged: (sessionId, pending) => { + // Guidance belongs to the session it was typed in, so only mirror the + // active session in the prompt footer. + if (sessionId === sessionManager.getActiveSessionId()) { + setPendingSupplementary(pending); + } + }, + onSupplementaryPromptInjected: (message) => { + setMessages((prev) => [...prev, message]); + }, onProcessStdout: (pid, chunk) => { const buf = processStdoutRef.current; const current = buf.get(pid) ?? ""; @@ -238,6 +263,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setPlanMode(false); setPendingPlanImplementation(null); setDismissedQuestionIds(new Set()); + setPendingSupplementary([]); await resetStaticView([]); await refreshSkills(); }, [sessionManager, resetStaticView, refreshSkills]); @@ -525,11 +551,26 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes [projectRoot, sessionManager] ); + const cancelLastQueuedPrompt = useCallback((): void => { + sessionManager.cancelSupplementaryPrompt(sessionManager.getActiveSessionId()); + }, [sessionManager]); + const handleSubmit = useCallback( (submission: PromptSubmission) => { + // Prompts submitted while a turn is running become supplemental guidance: + // SessionManager appends them as a user message right before the next LLM + // call of that turn, so the model can revise what it is doing. + if (busyRef.current) { + sessionManager.addSupplementaryPrompt(sessionManager.getActiveSessionId(), { + text: submission.text, + imageUrls: submission.imageUrls, + skills: submission.selectedSkills, + }); + return; + } void handlePrompt(submission); }, - [handlePrompt] + [handlePrompt, sessionManager] ); const handlePlanImplementationChoice = useCallback( @@ -584,6 +625,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setActiveAskPermissions(session?.askPermissions); setPlanMode(session?.planMode === true); setPendingPlanImplementation(null); + setPendingSupplementary(sessionManager.listPendingSupplementaryPrompts(sessionId)); if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { setPendingPermissionReply(null); } @@ -835,6 +877,9 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const expandedThinkingId = findExpandedThinkingId(messages); const pendingQuestion = useMemo(() => findPendingAskUserQuestion(messages, activeStatus), [activeStatus, messages]); const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId)); + + const queuedPrompts = useMemo(() => pendingSupplementary.map(buildQueuedPromptPreview), [pendingSupplementary]); + const loadingText = useMemo( () => busy @@ -1063,6 +1108,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes modelConfig={resolvedSettings} promptHistory={promptHistory} busy={busy} + queuedPrompts={queuedPrompts} + onRemoveQueuedPrompt={cancelLastQueuedPrompt} cursorLayoutKey={promptCursorLayoutKey} loadingText={loadingText} runningProcesses={runningProcesses} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 3e997771..0f61a7b5 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -44,6 +44,7 @@ import { } from "../core/file-mentions"; import type { FileMentionItem } from "../core/file-mentions"; import { readClipboardImageAsync } from "../core/clipboard"; +import { formatQueueHint } from "../core/prompt-queue"; import { useTerminalInput, usePasteHandling, @@ -60,6 +61,7 @@ import { useTerminalFocusReporting, } from "../hooks"; import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; +import { MAX_SUPPLEMENTARY_PROMPTS } from "@vegamo/deepcode-core"; import type { ModelConfigSelection, PermissionScope } from "@vegamo/deepcode-core"; import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; import type { SessionEntry, SkillInfo } from "@vegamo/deepcode-core"; @@ -96,6 +98,7 @@ type Props = { placeholder?: string; runningProcesses?: SessionEntry["processes"]; promptDraft?: PromptDraft | null; + queuedPrompts?: string[]; statusLineSegments?: StatusSegment[]; statusLineSeparator?: string; planMode: boolean; @@ -105,11 +108,18 @@ type Props = { onPlanModeChange: (enabled: boolean) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; + onRemoveQueuedPrompt?: () => void; onExitShortcut?: () => void; }; const PROMPT_PREFIX_WIDTH = 2; +/** Shared empty value so `queuedPrompts` keeps a stable identity for React.memo. */ +const EMPTY_QUEUED_PROMPTS: string[] = []; + +/** Number of queued prompts rendered individually before collapsing into a counter. */ +const MAX_VISIBLE_QUEUED_PROMPTS = 3; + const PromptPrefixLine = React.memo(function PromptPrefixLine(): React.ReactElement { return ( @@ -131,6 +141,7 @@ export const PromptInput = React.memo(function PromptInput({ placeholder, runningProcesses, promptDraft, + queuedPrompts = EMPTY_QUEUED_PROMPTS, statusLineSegments, statusLineSeparator, planMode, @@ -138,6 +149,7 @@ export const PromptInput = React.memo(function PromptInput({ onModelConfigChange, onInterrupt, onToggleProcessStdout, + onRemoveQueuedPrompt, onExitShortcut, onRawModeChange, onPlanModeChange, @@ -205,15 +217,16 @@ export const PromptInput = React.memo(function PromptInput({ : hasExpandedRegions ? " · ctrl+o collapse" : ""; + const queueHint = formatQueueHint(queuedPrompts.length); const busyStatusText = loadingText && loadingText.trim() - ? `${loadingText}${processOrPasteHint}` - : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`; + ? `${loadingText}${processOrPasteHint}${queueHint ? ` · ${queueHint}` : ""}` + : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}${queueHint ? ` · ${queueHint}` : ""}`; const footerText = statusMessage ? statusMessage : busy ? busyStatusText - : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; + : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}${queueHint ? ` · ${queueHint}` : ""}`; const showFooterText = useMemo( () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] @@ -466,7 +479,15 @@ export const PromptInput = React.memo(function PromptInput({ } if (busy && isPlainReturn) { - setStatusMessage("wait for the current response or press esc to interrupt"); + // While a turn is running, plain prompts become supplemental guidance for + // that turn so the user does not have to wait or interrupt. Slash commands + // keep the old behaviour because they change view state instead of sending + // a prompt. + if (findExactCommandForBuffer()) { + setStatusMessage("wait for the current response or press esc to interrupt"); + return; + } + submitCurrentBuffer(); return; } @@ -480,6 +501,12 @@ export const PromptInput = React.memo(function PromptInput({ return; } + if (key.backspace && isEmpty(buffer) && queuedPrompts.length > 0 && noModifier) { + onRemoveQueuedPrompt?.(); + setStatusMessage(`Removed the last queued guidance (${queuedPrompts.length - 1} waiting)`); + return; + } + if (key.delete) { updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s)); return; @@ -743,23 +770,29 @@ export const PromptInput = React.memo(function PromptInput({ } } - function submitCurrentBuffer(): void { - if (busy) { - setStatusMessage("wait for the current response or press esc to interrupt"); - return; + function findExactCommandForBuffer(): SlashCommandItem | null { + const trimmed = buffer.text.trim(); + if (!trimmed.startsWith("/")) { + return null; } + return findExactSlashCommand(slashItems, trimmed.split(/\s+/, 1)[0]); + } + function submitCurrentBuffer(): void { const trimmed = buffer.text.trim(); if (!trimmed && imageUrls.length === 0 && selectedSkills.length === 0) { return; } - if (trimmed.startsWith("/")) { - const exactMatch = findExactSlashCommand(slashItems, trimmed.split(/\s+/, 1)[0]); - if (exactMatch) { - handleSlashSelection(exactMatch); - return; - } + const exactMatch = findExactCommandForBuffer(); + if (exactMatch) { + handleSlashSelection(exactMatch); + return; + } + + if (busy && queuedPrompts.length >= MAX_SUPPLEMENTARY_PROMPTS) { + setStatusMessage(`Guidance queue is full (${MAX_SUPPLEMENTARY_PROMPTS}) — press esc to interrupt`); + return; } onSubmit({ @@ -768,6 +801,9 @@ export const PromptInput = React.memo(function PromptInput({ selectedSkills, planMode, }); + if (busy) { + setStatusMessage(`Guidance queued — read at the model's next step (${queuedPrompts.length + 1} waiting)`); + } resetPromptInput(); } @@ -810,6 +846,21 @@ export const PromptInput = React.memo(function PromptInput({ (shift+tab to cycle) ) : null} + {queuedPrompts.length > 0 ? ( + + {queuedPrompts.slice(0, MAX_VISIBLE_QUEUED_PROMPTS).map((preview, index) => ( + + guidance + {`${index + 1}. ${preview}`} + + ))} + {queuedPrompts.length > MAX_VISIBLE_QUEUED_PROMPTS ? ( + + {`guidance … ${queuedPrompts.length - MAX_VISIBLE_QUEUED_PROMPTS} more`} + + ) : null} + + ) : null} {/* Input */} ; permissions?: MessageToolPermission[]; userPrompt?: UserPromptContent; + /** User guidance that arrived while the turn was already running. */ + isSupplementary?: boolean; }; export type SessionMessage = { @@ -361,6 +363,17 @@ export type SkillInfo = { allowImplicitInvocation?: boolean; }; +export type SupplementaryPrompt = { + id: number; + text: string; + imageUrls: string[]; + skills?: SkillInfo[]; + createTime: string; +}; + +/** Maximum number of prompts that may wait for the next LLM call of a turn. */ +export const MAX_SUPPLEMENTARY_PROMPTS = 10; + export type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; @@ -387,6 +400,10 @@ export type SessionManagerOptions = { onLlmRetry?: (event: LlmRetryEvent) => void; onMcpStatusChanged?: () => void; onProcessStdout?: (pid: number, chunk: string) => void; + /** Fired when the pending supplemental guidance of a session changes. */ + onSupplementaryQueueChanged?: (sessionId: string, pending: SupplementaryPrompt[]) => void; + /** Fired after a supplemental prompt is appended to the session as a user message. */ + onSupplementaryPromptInjected?: (message: SessionMessage) => void; loadSharp?: SharpLoader; nonInteractive?: boolean; }; @@ -435,10 +452,15 @@ export class SessionManager { private readonly onLlmRetry?: (event: LlmRetryEvent) => void; private readonly onMcpStatusChanged?: () => void; private readonly onProcessStdout?: (pid: number, chunk: string) => void; + private readonly onSupplementaryQueueChanged?: (sessionId: string, pending: SupplementaryPrompt[]) => void; + private readonly onSupplementaryPromptInjected?: (message: SessionMessage) => void; private readonly nonInteractive: boolean; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); + /** Supplemental guidance that arrived while a turn was already running. */ + private readonly supplementaryPrompts = new Map(); + private supplementaryPromptNextId = 1; private readonly processTimeoutControls = new Map(); private readonly liveProcessKeys = new Set(); private readonly toolExecutor: ToolExecutor; @@ -458,6 +480,8 @@ export class SessionManager { this.onLlmRetry = options.onLlmRetry; this.onMcpStatusChanged = options.onMcpStatusChanged; this.onProcessStdout = options.onProcessStdout; + this.onSupplementaryQueueChanged = options.onSupplementaryQueueChanged; + this.onSupplementaryPromptInjected = options.onSupplementaryPromptInjected; this.nonInteractive = options.nonInteractive === true; this.loadSharp = options.loadSharp; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager, options.loadSharp); @@ -1452,6 +1476,109 @@ ${agentInstructions} this.onAssistantMessage(message, false); } + /** + * Queue guidance that the user sent while a turn was already running. + * + * The prompt becomes a user message right before the next LLM call of the + * running turn, so the model reads it together with the work it already did and + * can override the earlier instructions. Returns the queued entry, or null when + * the queue is full, the prompt is empty, or the session is unknown. + */ + addSupplementaryPrompt( + sessionId: string | null | undefined, + prompt: { text?: string; imageUrls?: string[]; skills?: SkillInfo[] } + ): SupplementaryPrompt | null { + if (!sessionId || !this.getSession(sessionId)) { + return null; + } + + const text = (prompt.text ?? "").trim(); + const imageUrls = (prompt.imageUrls ?? []).filter(Boolean); + const skills = prompt.skills && prompt.skills.length > 0 ? prompt.skills : undefined; + if (!text && imageUrls.length === 0 && !skills) { + return null; + } + + const pending = this.supplementaryPrompts.get(sessionId) ?? []; + if (pending.length >= MAX_SUPPLEMENTARY_PROMPTS) { + return null; + } + + const entry: SupplementaryPrompt = { + id: this.supplementaryPromptNextId, + text, + imageUrls, + skills, + createTime: new Date().toISOString(), + }; + this.supplementaryPromptNextId += 1; + this.setSupplementaryPrompts(sessionId, [...pending, entry]); + return entry; + } + + /** Drop one pending entry; without an id the newest entry is removed. */ + cancelSupplementaryPrompt(sessionId: string | null | undefined, id?: number): boolean { + const pending = sessionId ? this.supplementaryPrompts.get(sessionId) : undefined; + if (!sessionId || !pending || pending.length === 0) { + return false; + } + + const index = id === undefined ? pending.length - 1 : pending.findIndex((entry) => entry.id === id); + if (index === -1) { + return false; + } + + const next = pending.slice(); + next.splice(index, 1); + this.setSupplementaryPrompts(sessionId, next); + return true; + } + + /** Pending guidance of a session, oldest first. */ + listPendingSupplementaryPrompts(sessionId: string | null | undefined): SupplementaryPrompt[] { + if (!sessionId) { + return []; + } + return (this.supplementaryPrompts.get(sessionId) ?? []).map((entry) => ({ ...entry })); + } + + countPendingSupplementaryPrompts(sessionId: string | null | undefined): number { + return sessionId ? (this.supplementaryPrompts.get(sessionId)?.length ?? 0) : 0; + } + + private setSupplementaryPrompts(sessionId: string, pending: SupplementaryPrompt[]): void { + if (pending.length === 0) { + this.supplementaryPrompts.delete(sessionId); + } else { + this.supplementaryPrompts.set(sessionId, pending); + } + this.onSupplementaryQueueChanged?.(sessionId, this.listPendingSupplementaryPrompts(sessionId)); + } + + /** + * Append every pending supplemental prompt as a user message of the running + * turn. Called before each LLM call so the guidance is read inside the same + * turn instead of waiting for the next one. + */ + private async flushSupplementaryPrompts(sessionId: string): Promise { + const pending = this.supplementaryPrompts.get(sessionId); + if (!pending || pending.length === 0) { + return 0; + } + + this.setSupplementaryPrompts(sessionId, []); + for (const entry of pending) { + const skills = await this.normalizeSkills(entry.skills, sessionId); + this.appendSkillMessages(sessionId, skills); + const prepared = this.preparePromptImages(sessionId, { text: entry.text, imageUrls: entry.imageUrls }); + const message = this.buildUserMessage(sessionId, prepared); + message.meta = { ...(message.meta ?? {}), isSupplementary: true }; + this.appendSessionMessage(sessionId, message); + this.onSupplementaryPromptInjected?.(message); + } + return pending.length; + } + async handleUserPrompt(userPrompt: UserPromptContent): Promise { const controller = new AbortController(); this.activePromptController = controller; @@ -1768,6 +1895,14 @@ ${agentInstructions} await this.compactSession(sessionId, sessionController.signal); } + // Guidance sent while this turn was running becomes part of the request + // about to be sent, so the model can revise what it is doing instead of + // waiting for the next turn. + await this.flushSupplementaryPrompts(sessionId); + if (this.isInterrupted(sessionId)) { + return; + } + const sessionMessages = await this.attachPromptImagesForRequest( this.prepareSessionMessagesForRequest(this.listSessionMessages(sessionId)), model, @@ -1925,6 +2060,16 @@ ${agentInstructions} } if (!toolCalls) { + // Keep the turn alive while guidance is still waiting: the next + // iteration injects it and asks the model to revise its answer. + if (this.countPendingSupplementaryPrompts(sessionId) > 0) { + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + status: "processing", + updateTime: new Date().toISOString(), + })); + continue; + } return; } } @@ -2655,6 +2800,7 @@ ${agentInstructions} controller.abort(); } this.sessionControllers.delete(sessionId); + this.setSupplementaryPrompts(sessionId, []); if (options.removeMessages) { this.removeSessionMessages([sessionId]); try { diff --git a/packages/core/src/tests/supplementary-prompts.test.ts b/packages/core/src/tests/supplementary-prompts.test.ts new file mode 100644 index 00000000..283b3401 --- /dev/null +++ b/packages/core/src/tests/supplementary-prompts.test.ts @@ -0,0 +1,372 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { MAX_SUPPLEMENTARY_PROMPTS, SessionManager, type SessionMessage } from "../session"; +import type { MultimodalMode } from "../common/model-capabilities"; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; + +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +type CapturedRequest = { + messages: Array<{ role: string; content: unknown }>; +}; + +type TestClient = { + chat: { completions: { create: (request: unknown) => Promise } }; +}; + +function isSkillMatchingRequest(request: any): boolean { + return request?.response_format?.type === "json_object"; +} + +function createSkillMatchingResponse(): unknown { + return { choices: [{ message: { content: JSON.stringify({ skillNames: [] }) } }] }; +} + +function createChatResponse(content: string): unknown { + return { + choices: [{ message: { content } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; +} + +function createClientMock(options: { + responses: unknown[]; + requests: CapturedRequest[]; + onRequest?: (request: CapturedRequest, index: number) => void; +}): TestClient { + return { + chat: { + completions: { + create: async (request: unknown) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + const captured = request as CapturedRequest; + options.onRequest?.(captured, options.requests.length); + options.requests.push(captured); + const response = options.responses.shift(); + assert.ok(response, "expected a queued chat response"); + if (response instanceof Error) { + throw response; + } + return response; + }, + }, + }, + }; +} + +function createTestManager(options: { + projectRoot: string; + client: TestClient; + model?: string; + multimodal?: MultimodalMode; + onSupplementaryQueueChanged?: (sessionId: string, pending: unknown[]) => void; + onSupplementaryPromptInjected?: (message: SessionMessage) => void; +}): SessionManager { + const model = options.model ?? "test-model"; + return new SessionManager({ + projectRoot: options.projectRoot, + createOpenAIClient: () => ({ + client: options.client as any, + model, + thinkingEnabled: false, + baseURL: "https://api.deepseek.com", + machineId: "machine-id-supplementary", + telemetryEnabled: false, + }), + getResolvedSettings: () => ({ model, multimodal: options.multimodal }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + onSupplementaryQueueChanged: options.onSupplementaryQueueChanged as any, + onSupplementaryPromptInjected: options.onSupplementaryPromptInjected, + }); +} + +function stubTelemetry(): void { + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; +} + +function findInjectedMessages(messages: SessionMessage[]): SessionMessage[] { + return messages.filter((message) => message.meta?.isSupplementary === true); +} + +test("addSupplementaryPrompt queues guidance in order and notifies the caller", () => { + const workspace = createTempDir("deepcode-supplementary-workspace-"); + const home = createTempDir("deepcode-supplementary-home-"); + setHomeDir(home); + + const updates: Array<{ sessionId: string; count: number }> = []; + const client = createClientMock({ responses: [], requests: [] }); + const manager = createTestManager({ + projectRoot: workspace, + client, + onSupplementaryQueueChanged: (sessionId, pending) => updates.push({ sessionId, count: pending.length }), + }); + + const sessionId = "session-supplementary"; + (manager as any).updateSessionEntry = () => null; + (manager as any).getSession = () => ({ id: sessionId }); + + const first = manager.addSupplementaryPrompt(sessionId, { text: " use postgres " }); + const second = manager.addSupplementaryPrompt(sessionId, { + text: "and add an index", + imageUrls: ["https://example.com/a.png"], + }); + + assert.equal(first?.id, 1); + assert.equal(first?.text, "use postgres"); + assert.equal(second?.id, 2); + assert.deepEqual(second?.imageUrls, ["https://example.com/a.png"]); + assert.deepEqual( + manager.listPendingSupplementaryPrompts(sessionId).map((entry) => entry.text), + ["use postgres", "and add an index"] + ); + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), 2); + assert.deepEqual(updates, [ + { sessionId, count: 1 }, + { sessionId, count: 2 }, + ]); +}); + +test("listPendingSupplementaryPrompts returns copies that cannot mutate the queue", () => { + const manager = createTestManager({ + projectRoot: createTempDir("deepcode-supplementary-copy-workspace-"), + client: createClientMock({ responses: [], requests: [] }), + }); + + const sessionId = "session-copy"; + (manager as any).getSession = () => ({ id: sessionId }); + manager.addSupplementaryPrompt(sessionId, { text: "keep me" }); + + const pending = manager.listPendingSupplementaryPrompts(sessionId); + pending[0]!.text = "mutated"; + pending.push({ id: 99, text: "extra", imageUrls: [], createTime: "" }); + + assert.deepEqual( + manager.listPendingSupplementaryPrompts(sessionId).map((entry) => entry.text), + ["keep me"] + ); +}); + +test("addSupplementaryPrompt ignores empty prompts, unknown sessions and a full queue", () => { + const manager = createTestManager({ + projectRoot: createTempDir("deepcode-supplementary-limits-workspace-"), + client: createClientMock({ responses: [], requests: [] }), + }); + + const sessionId = "session-limits"; + (manager as any).getSession = (id: string) => (id === sessionId ? { id: sessionId } : null); + + assert.equal(manager.addSupplementaryPrompt(sessionId, { text: " " }), null); + assert.equal(manager.addSupplementaryPrompt("missing-session", { text: "hello" }), null); + assert.equal(manager.addSupplementaryPrompt(null, { text: "hello" }), null); + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), 0); + + for (let index = 0; index < MAX_SUPPLEMENTARY_PROMPTS; index += 1) { + assert.ok(manager.addSupplementaryPrompt(sessionId, { text: `prompt ${index}` })); + } + assert.equal(manager.addSupplementaryPrompt(sessionId, { text: "overflow" }), null); + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), MAX_SUPPLEMENTARY_PROMPTS); +}); + +test("cancelSupplementaryPrompt removes the newest entry by default and any entry by id", () => { + const manager = createTestManager({ + projectRoot: createTempDir("deepcode-supplementary-cancel-workspace-"), + client: createClientMock({ responses: [], requests: [] }), + }); + + const sessionId = "session-cancel"; + (manager as any).getSession = () => ({ id: sessionId }); + manager.addSupplementaryPrompt(sessionId, { text: "first" }); + manager.addSupplementaryPrompt(sessionId, { text: "second" }); + manager.addSupplementaryPrompt(sessionId, { text: "third" }); + + assert.equal(manager.cancelSupplementaryPrompt(sessionId), true); + assert.deepEqual( + manager.listPendingSupplementaryPrompts(sessionId).map((entry) => entry.text), + ["first", "second"] + ); + + assert.equal(manager.cancelSupplementaryPrompt(sessionId, 1), true); + assert.deepEqual( + manager.listPendingSupplementaryPrompts(sessionId).map((entry) => entry.text), + ["second"] + ); + + assert.equal(manager.cancelSupplementaryPrompt(sessionId, 404), false); + assert.equal(manager.cancelSupplementaryPrompt("missing-session"), false); + assert.equal(manager.cancelSupplementaryPrompt(sessionId), true); + assert.equal(manager.cancelSupplementaryPrompt(sessionId), false); +}); + +test("guidance sent while the model is working is injected into the running turn", async () => { + const workspace = createTempDir("deepcode-supplementary-turn-workspace-"); + const home = createTempDir("deepcode-supplementary-turn-home-"); + setHomeDir(home); + stubTelemetry(); + + const requests: CapturedRequest[] = []; + const injected: SessionMessage[] = []; + const client = createClientMock({ + responses: [createChatResponse("initial answer"), createChatResponse("revised answer")], + requests, + // Simulate the user typing a new instruction while the first LLM call runs. + onRequest: (_request, index) => { + if (index === 0) { + assert.equal( + manager.addSupplementaryPrompt(sessionId, { text: "stop, use apache instead" })?.text, + "stop, use apache instead" + ); + } + }, + }); + const manager = createTestManager({ + projectRoot: workspace, + client, + onSupplementaryPromptInjected: (message) => injected.push(message), + }); + + const sessionId = await manager.createSession({ text: "install nginx" }); + requests.length = 0; + + await manager.activateSession(sessionId); + + // The first request of the turn has no guidance yet, the second one does. + assert.equal(requests.length, 2); + const firstTurnMessages = requests[0]!.messages.filter((message) => message.role === "user"); + const revisionMessages = requests[1]!.messages.filter((message) => message.role === "user"); + assert.deepEqual( + firstTurnMessages.map((message) => message.content), + ["install nginx"] + ); + assert.deepEqual( + revisionMessages.map((message) => message.content), + ["install nginx", "stop, use apache instead"] + ); + + // The guidance is persisted as a user message that arrives after the work done so far. + const messages = manager.listSessionMessages(sessionId); + const stored = findInjectedMessages(messages); + assert.equal(stored.length, 1); + assert.equal(stored[0]!.content, "stop, use apache instead"); + assert.equal(stored[0]!.visible, true); + assert.equal(stored[0]!.meta?.userPrompt?.text, "stop, use apache instead"); + assert.equal(injected.length, 1); + assert.equal(injected[0]!.id, stored[0]!.id); + + const assistantIndex = messages.findIndex((message) => message.content === "initial answer"); + const injectedIndex = messages.findIndex((message) => message.id === stored[0]!.id); + assert.ok(assistantIndex !== -1 && injectedIndex > assistantIndex); + + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), 0); +}); + +test("guidance queued before a turn is part of the first LLM call of that turn", async () => { + const workspace = createTempDir("deepcode-supplementary-pending-workspace-"); + const home = createTempDir("deepcode-supplementary-pending-home-"); + setHomeDir(home); + stubTelemetry(); + + const requests: CapturedRequest[] = []; + const client = createClientMock({ + responses: [createChatResponse("initial answer"), createChatResponse("answer with guidance")], + requests, + }); + const manager = createTestManager({ projectRoot: workspace, client }); + + const sessionId = await manager.createSession({ text: "first task" }); + requests.length = 0; + + manager.addSupplementaryPrompt(sessionId, { text: "prefer TypeScript" }); + await manager.activateSession(sessionId); + + assert.equal(requests.length, 1); + assert.deepEqual( + requests[0]!.messages.filter((message) => message.role === "user").map((message) => message.content), + ["first task", "prefer TypeScript"] + ); + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), 0); +}); + +test("guidance with an image is injected as a user message that carries the image", async () => { + const workspace = createTempDir("deepcode-supplementary-image-workspace-"); + const home = createTempDir("deepcode-supplementary-image-home-"); + setHomeDir(home); + stubTelemetry(); + + const requests: CapturedRequest[] = []; + const client = createClientMock({ + responses: [createChatResponse("initial answer"), createChatResponse("locked")], + requests, + }); + const manager = createTestManager({ + projectRoot: workspace, + client, + model: "test-vision-model", + multimodal: "on", + }); + + const sessionId = await manager.createSession({ text: "build the layout" }); + requests.length = 0; + + manager.addSupplementaryPrompt(sessionId, { + text: "match this design", + imageUrls: ["https://example.com/design.png"], + }); + await manager.activateSession(sessionId); + + const userMessages = requests[0]!.messages.filter((message) => message.role === "user"); + assert.deepEqual( + userMessages.map((message) => message.content), + [ + "build the layout", + [ + { type: "text", text: "match this design" }, + { type: "image_url", image_url: { url: "https://example.com/design.png" } }, + ], + ] + ); + + const stored = findInjectedMessages(manager.listSessionMessages(sessionId)); + assert.equal(stored.length, 1); + assert.deepEqual(stored[0]!.meta?.userPrompt?.imageUrls, ["https://example.com/design.png"]); +}); From e694e2329632e36620d6c4270256eeb2a9d61f02 Mon Sep 17 00:00:00 2001 From: Mulyawan Sentosa Date: Thu, 24 Sep 2026 10:42:15 +0700 Subject: [PATCH 2/2] feat: cut a streaming answer short when new guidance arrives Guidance injected before the next LLM call still had to wait for the answer that was already streaming, so a long text answer could not be redirected until it finished on its own. The session now tracks the streaming request separately from the turn. When the user sends guidance while an answer is streaming, that request is aborted, whatever the model already wrote is kept as an assistant message marked meta.interrupted, and the loop immediately issues a new request that carries the guidance. Tools are never interrupted: steering only targets the model stream, so a running command finishes first and the guidance is injected at the next request boundary. - session: steerActiveSession(), a per-request steer signal released once the request settles, and LlmSteeredError carrying the partial answer - session: a steered answer is not an interruption - the turn continues, the status stays completed, and the request is not retried - settings: steerMode ("interrupt" by default, "queue" keeps the old behaviour) - cli: submitting while busy calls steerActiveSession() when steerMode is "interrupt"; the cut-short answer is labelled in the transcript - tests: core covers the aborted stream, the follow-up request payload, the kept partial answer, and the no-op case; settings cover steerMode resolution - docs: configuration table and section, plus the README notes Refs #113, #117 --- README-en.md | 9 +- README.md | 5 +- docs/configuration.md | 14 ++ docs/configuration_en.md | 15 ++ packages/cli/src/tests/exec-runner.test.ts | 1 + .../src/ui/components/MessageView/index.tsx | 1 + packages/cli/src/ui/views/App.tsx | 8 +- packages/core/src/common/llm-retry.ts | 20 +++ packages/core/src/index.ts | 1 + packages/core/src/session.ts | 105 +++++++++++- packages/core/src/settings.ts | 20 +++ .../src/tests/settings-and-notify.test.ts | 22 +++ .../src/tests/supplementary-prompts.test.ts | 151 +++++++++++++++++- 13 files changed, 363 insertions(+), 9 deletions(-) diff --git a/README-en.md b/README-en.md index 339973d8..aa134727 100644 --- a/README-en.md +++ b/README-en.md @@ -98,9 +98,12 @@ Skills are discovered from these locations, in priority order: While a turn is running, `Enter` no longer blocks and does not have to wait: the prompt becomes supplemental guidance, appended to the conversation right before the model's next step of that turn. The model therefore reads it together with the work it already did and can revise or -supersede the earlier instructions. Up to 10 messages can wait; press `Backspace` on an empty -prompt to drop the last one. `Esc` still interrupts the turn immediately, and slash commands -still wait for the turn to finish. +supersede the earlier instructions. When the model is in the middle of writing an answer, that +answer is cut short and whatever it already wrote stays in the conversation; set +`steerMode: "queue"` to wait for the next request boundary instead. Up to 10 messages can wait; +press `Backspace` on an empty prompt to drop the last one. Running commands are never +interrupted, `Esc` still interrupts the turn immediately, and slash commands still wait for the +turn to finish. ## Supported Models diff --git a/README.md b/README.md index 3d509638..24bb6d85 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,9 @@ Skills 会按以下优先级扫描: AI 正在回复时,`Enter` 不再被拒绝,也不必等到本轮结束:消息会作为补充指引,在本轮下一次 LLM 调用前作为 user 消息追加到对话中。模型因此能读到它,并结合已完成的工作修改甚至推翻之前的 -指令。最多可排队 10 条;输入框为空时按 `Backspace` 可移除最后一条。按 `Esc` 仍会立即中断本轮, -斜杠命令仍需等待本轮结束。 +指令。如果模型正在输出回答,该回答会被截断,已生成的内容仍保留在对话里;可用 `steerMode: "queue"` +改为等到下次请求边界再注入。最多可排队 10 条;输入框为空时按 `Backspace` 可移除最后一条。 +正在执行的命令不会被中断,按 `Esc` 仍会立即中断本轮,斜杠命令仍需等待本轮结束。 ## 支持的模型 diff --git a/docs/configuration.md b/docs/configuration.md index 7c141ecd..e0422f33 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,6 +33,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | | `reasoningEffort` | string | 推理强度,可选 `"low"`、`"high"` 或 `"max"`(默认 `"max"`) | | `multimodal` | string | 多模态(图片)能力开关,可选 `"default"`、`"on"` 或 `"off"`(默认 `"default"`) | +| `steerMode` | string | 模型正在输出时发送新指令的处理方式:`"interrupt"`(截断当前回复、立即读取新指令,默认)或 `"queue"`(等到下次请求边界再注入) | | `filesApiEnabled` | boolean | 是否通过 DeepSeek Files API 发送图片(默认 `false`) | | `filesApiTimeoutMs` | number | 单张图片 Files API 处理超时,默认 `60000`,最大 `600000` 毫秒 | | `fileExpiresAfterSeconds` | number | 远端文件有效期,默认 `604800` 秒 | @@ -106,6 +107,19 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 当使用的模型未内置在已知模型列表中、或其实际能力与默认判定不符时,可通过该配置覆盖。 +#### `steerMode` — 模型输出中追加指令 + +控制模型还在输出回答时你发送新指令的处理方式: + +| 值 | 说明 | +| ----------- | ------------------------------------------------------------ | +| `interrupt` | 截断正在流式输出的回答,已生成的内容保留在对话中,新指令立即被读取(默认值) | +| `queue` | 新指令进入队列,在本轮下一次 LLM 调用前注入 | + +两种方式都会把新指令作为本轮对话中的 user 消息,模型因此可以修改甚至推翻它原本正在执行的指令。 +正在执行的命令不会被中断:steering 只影响模型输出流,因此工具会先执行完,新指令在下一个请求边界注入。 +按 `Esc` 仍会中断整个轮次。 + #### DeepSeek Files API 当 `BASE_URL` 为 `https://api.deepseek.com` 时,设置 `filesApiEnabled: true` 后,Deep Code 会将图片上传到固定的 `https://api.deepseek.com/files`,并在聊天请求中使用 `file_id`。其他 API 地址不会启用该功能。上传或缓存刷新失败时,本次请求直接失败;关闭开关时图片处理逻辑保持不变。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 1620951c..7807cb27 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -33,6 +33,7 @@ The following are all the top-level fields supported in `settings.json`, along w | `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| | `reasoningEffort` | string | Reasoning intensity: `"low"`, `"high"`, or `"max"` (default `"max"`) | | `multimodal` | string | Multimodal (image) capability override: `"default"`, `"on"`, or `"off"` (default `"default"`) | +| `steerMode` | string | How a prompt sent while the model is writing is handled: `"interrupt"` (cut the answer short so the prompt is read immediately, default) or `"queue"` (wait for the next request boundary) | | `filesApiEnabled` | boolean | Send images through the DeepSeek Files API (default `false`) | | `filesApiTimeoutMs` | number | Per-image Files API timeout; defaults to `60000`, maximum `600000` ms | | `fileExpiresAfterSeconds` | number | Remote file lifetime, default `604800` seconds | @@ -106,6 +107,20 @@ Controls whether the current model is treated as a multimodal model that accepts Use this to override the default detection when your model is not in the known-model list, or when its actual capability differs from the default. +#### `steerMode` — Steering While the Model Is Writing + +Controls what happens when you send a prompt while the model is still producing an answer: + +| Value | Description | +| ----------- | --------------------------------------------------------------------------- | +| `interrupt` | The answer that is streaming is cut short; its text is kept in the conversation and the prompt is read immediately (default) | +| `queue` | The prompt waits and is injected before the next LLM call of the running turn | + +Either way the prompt becomes a user message of the running turn, so the model can revise or +supersede the instructions it was already following. Running commands are never interrupted: +steering only affects the model stream, so a tool that is executing finishes first and the +prompt is injected at the next request boundary. Pressing `Esc` still interrupts the whole turn. + #### DeepSeek Files API When `BASE_URL` is `https://api.deepseek.com`, enabling `filesApiEnabled` uploads images to the fixed `https://api.deepseek.com/files` endpoint and sends `file_id` references in chat requests. Other API endpoints do not enable this feature. An upload or cache-refresh failure fails the request; disabling the setting preserves the existing image path. diff --git a/packages/cli/src/tests/exec-runner.test.ts b/packages/cli/src/tests/exec-runner.test.ts index 5b86b575..005cbeab 100644 --- a/packages/cli/src/tests/exec-runner.test.ts +++ b/packages/cli/src/tests/exec-runner.test.ts @@ -33,6 +33,7 @@ function createSettings( debugLogEnabled: false, telemetryEnabled: false, multimodal: "default", + steerMode: "interrupt", filesApiEnabled: false, filesApiTimeoutMs: 60_000, fileExpiresAfterSeconds: 604_800, diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx index 9f07eae6..88bcf635 100644 --- a/packages/cli/src/ui/components/MessageView/index.tsx +++ b/packages/cli/src/ui/components/MessageView/index.tsx @@ -88,6 +88,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return {seg.body}; }) : null} + {message.meta?.interrupted ? — superseded by your guidance : null} ); diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 15f63d6f..5ea2f78a 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -566,11 +566,17 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes imageUrls: submission.imageUrls, skills: submission.selectedSkills, }); + // With `steerMode: "interrupt"` (the default) the answer that is streaming is + // cut short so the guidance is read straight away; `"queue"` only waits for the + // next request boundary. Running tools are never interrupted. + if (resolveCurrentSettings(projectRoot).steerMode !== "queue") { + sessionManager.steerActiveSession(); + } return; } void handlePrompt(submission); }, - [handlePrompt, sessionManager] + [handlePrompt, projectRoot, sessionManager] ); const handlePlanImplementationChoice = useCallback( diff --git a/packages/core/src/common/llm-retry.ts b/packages/core/src/common/llm-retry.ts index 977854a8..b01aac3a 100644 --- a/packages/core/src/common/llm-retry.ts +++ b/packages/core/src/common/llm-retry.ts @@ -31,6 +31,26 @@ export class LlmStreamDisconnectedError extends Error { } } +/** + * Raised when a streaming answer is cut short because the user sent new guidance + * while the model was still writing. The partial answer is carried along so the + * caller can keep it in the conversation before continuing with the guidance. + */ +export class LlmSteeredError extends Error { + constructor( + readonly partialContent: string, + readonly partialThinking: string | null + ) { + super("Model answer was superseded by new user guidance."); + this.name = "LlmSteeredError"; + } + + /** True when the model had already produced something worth keeping. */ + hasPartialAnswer(): boolean { + return this.partialContent.trim().length > 0 || (this.partialThinking ?? "").trim().length > 0; + } +} + export function getLlmRetryDelayMs(attempt: number, random: () => number = Math.random): number { const exponentialDelay = BASE_RETRY_DELAY_MS * 2 ** Math.max(0, attempt - 1); const jitter = 0.9 + Math.min(1, Math.max(0, random())) * 0.2; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a2519cdd..cb205aee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export type { PermissionDefaultMode, McpServerConfig, ReasoningEffort, + SteerMode, StatusLineSettings, ResolvedStatusLineSettings, StatusLineProviderConfig, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 53ac377d..e4ca121c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -81,6 +81,7 @@ import { LLM_STREAM_IDLE_TIMEOUT_MS, LlmStreamDisconnectedError, LlmStreamIdleTimeoutError, + LlmSteeredError, MAX_LLM_RETRIES, waitForLlmRetry, } from "./common/llm-retry"; @@ -316,6 +317,8 @@ export type MessageMeta = { userPrompt?: UserPromptContent; /** User guidance that arrived while the turn was already running. */ isSupplementary?: boolean; + /** Assistant output that was cut short because new guidance superseded it. */ + interrupted?: boolean; }; export type SessionMessage = { @@ -461,6 +464,10 @@ export class SessionManager { /** Supplemental guidance that arrived while a turn was already running. */ private readonly supplementaryPrompts = new Map(); private supplementaryPromptNextId = 1; + /** Abort handle of the LLM request each session is currently streaming. */ + private readonly steerControllers = new Map(); + /** Sessions whose in-flight answer was superseded by new user guidance. */ + private readonly steeredSessions = new Set(); private readonly processTimeoutControls = new Map(); private readonly liveProcessKeys = new Set(); private readonly toolExecutor: ToolExecutor; @@ -723,7 +730,12 @@ export class SessionManager { try { return await this.createChatCompletionStreamAttempt(client, request, options, sessionId, debug, requestId); } catch (error) { - if (signal?.aborted || retryCount >= MAX_LLM_RETRIES || !isRetryableLlmError(error)) { + if ( + error instanceof LlmSteeredError || + signal?.aborted || + retryCount >= MAX_LLM_RETRIES || + !isRetryableLlmError(error) + ) { throw error; } const attempt = retryCount + 1; @@ -970,6 +982,11 @@ export class SessionManager { throw new LlmStreamDisconnectedError(); } } catch (error) { + if (sessionId && this.steeredSessions.has(sessionId)) { + // The user sent new guidance while this answer was streaming. Hand the + // partial answer to the caller instead of reporting a failure. + throw new LlmSteeredError(content, reasoningContent.length > 0 ? reasoningContent : null); + } const streamError = idleTimedOut ? new LlmStreamIdleTimeoutError() : error; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), @@ -1579,6 +1596,79 @@ ${agentInstructions} return pending.length; } + /** + * Cut short the answer the model is currently writing so the guidance the user + * just sent is read now instead of after that answer finishes. + * + * Nothing is interrupted when no answer is streaming (for example while a tool + * is executing): the guidance then waits for the next request boundary, so a + * running command is never killed by normal typing. Returns whether an answer + * was cut short. + */ + steerActiveSession(): boolean { + const sessionId = this.activeSessionId; + if (!sessionId) { + return false; + } + + const controller = this.steerControllers.get(sessionId); + if (!controller) { + return false; + } + + this.steeredSessions.add(sessionId); + if (!controller.signal.aborted) { + controller.abort(new Error("Superseded by new user guidance.")); + } + return true; + } + + /** + * Keep the part of the answer the model produced before it was steered, so the + * conversation still shows what it had said when the user changed direction. + */ + private appendSteeredAnswer(sessionId: string, error: LlmSteeredError): void { + if (!error.hasPartialAnswer()) { + return; + } + + const message = this.buildAssistantMessage(sessionId, error.partialContent, null, error.partialThinking ?? ""); + message.meta = { ...(message.meta ?? {}), interrupted: true }; + this.appendSessionMessage(sessionId, message); + this.onAssistantMessage(message, false); + } + + /** + * Signal for one LLM request: it aborts when the turn is interrupted or when the + * answer is superseded by new guidance, and it is released once the request is + * settled so `steerActiveSession` only ever targets a live stream. + */ + private beginRequestSteer(sessionId: string, turnSignal: AbortSignal): { signal: AbortSignal; release: () => void } { + const steerController = new AbortController(); + const forwardTurnAbort = () => { + if (!steerController.signal.aborted) { + steerController.abort(turnSignal.reason); + } + }; + if (turnSignal.aborted) { + forwardTurnAbort(); + } else { + turnSignal.addEventListener("abort", forwardTurnAbort, { once: true }); + } + this.steerControllers.set(sessionId, steerController); + + return { + signal: steerController.signal, + release: () => { + turnSignal.removeEventListener("abort", forwardTurnAbort); + if (this.steerControllers.get(sessionId) === steerController) { + this.steerControllers.delete(sessionId); + } + this.steeredSessions.delete(sessionId); + }, + }; + } + async handleUserPrompt(userPrompt: UserPromptContent): Promise { const controller = new AbortController(); this.activePromptController = controller; @@ -1933,6 +2023,7 @@ ${agentInstructions} references: [] as DeepSeekFileReference[], }; const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); + const steer = this.beginRequestSteer(sessionId, sessionController.signal); const request = () => this.createChatCompletionStream( client, @@ -1943,7 +2034,7 @@ ${agentInstructions} tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), ...thinkingOptions, }, - { signal: sessionController.signal }, + { signal: steer.signal }, sessionId, { enabled: debugLogEnabled, @@ -1956,6 +2047,12 @@ ${agentInstructions} try { response = await request(); } catch (error) { + if (error instanceof LlmSteeredError && !sessionController.signal.aborted) { + // The user changed direction while this answer was streaming: keep what + // the model already wrote, then let the loop continue with the guidance. + this.appendSteeredAnswer(sessionId, error); + continue; + } if (!filesSettings.enabled || prepared.references.length === 0 || !this.isRejectedDeepSeekFile(error)) { throw error; } @@ -1970,6 +2067,8 @@ ${agentInstructions} sessionController.signal ); response = await request(); + } finally { + steer.release(); } const message = response.choices?.[0]?.message; @@ -2801,6 +2900,8 @@ ${agentInstructions} } this.sessionControllers.delete(sessionId); this.setSupplementaryPrompts(sessionId, []); + this.steerControllers.delete(sessionId); + this.steeredSessions.delete(sessionId); if (options.removeMessages) { this.removeSessionMessages([sessionId]); try { diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 6bb59b97..e9f2b2fe 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -84,6 +84,8 @@ export type ResolvedStatusLineSettings = { providers: StatusLineProviderConfig[]; }; +export type SteerMode = "interrupt" | "queue"; + export type DeepcodingSettings = { env?: DeepcodingEnv; contextWindow?: number | string; @@ -97,6 +99,8 @@ export type DeepcodingSettings = { notify?: string; webSearchTool?: string; multimodal?: MultimodalMode; + /** How a prompt sent while the model is writing is handled. Defaults to `interrupt`. */ + steerMode?: SteerMode; filesApiEnabled?: boolean; filesApiTimeoutMs?: number; fileExpiresAfterSeconds?: number; @@ -124,6 +128,7 @@ export type ResolvedDeepcodingSettings = { notify?: string; webSearchTool?: string; multimodal: MultimodalMode; + steerMode: SteerMode; filesApiEnabled: boolean; filesApiTimeoutMs: number; fileExpiresAfterSeconds: number; @@ -204,6 +209,17 @@ function resolveMultimodalMode(value: unknown): MultimodalMode | undefined { return undefined; } +function resolveSteerMode(value: unknown): SteerMode | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "interrupt" || normalized === "queue") { + return normalized; + } + return undefined; +} + function parseBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") { return value; @@ -663,6 +679,9 @@ export function resolveSettingsSources( resolveMultimodalMode(userEnv.MULTIMODAL) ?? "default"; + const steerMode = + resolveSteerMode(projectSettings?.steerMode) ?? resolveSteerMode(userSettings?.steerMode) ?? "interrupt"; + const filesApiEnabled = baseURL === DEFAULT_BASE_URL && (parseBoolean(projectSettings?.filesApiEnabled) ?? parseBoolean(userSettings?.filesApiEnabled) ?? false); @@ -713,6 +732,7 @@ export function resolveSettingsSources( notify: notify || undefined, webSearchTool: webSearchTool || undefined, multimodal, + steerMode, filesApiEnabled, filesApiTimeoutMs, fileExpiresAfterSeconds, diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index 1c449376..112ded9d 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -171,6 +171,28 @@ test("resolveSettings reads top-level multimodal and ignores invalid values", () assert.equal(invalid.multimodal, "default"); }); +test("resolveSettings defaults steerMode to interrupt and ignores invalid values", () => { + const defaults = resolveSettings( + {}, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + const queued = resolveSettings( + { steerMode: "queue" }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + const invalid = resolveSettings( + { steerMode: "sometimes" as never }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + + assert.equal(defaults.steerMode, "interrupt"); + assert.equal(queued.steerMode, "queue"); + assert.equal(invalid.steerMode, "interrupt"); +}); + test("resolveSettings reads MULTIMODAL from env", () => { const resolved = resolveSettings( { env: { MULTIMODAL: "off" } }, diff --git a/packages/core/src/tests/supplementary-prompts.test.ts b/packages/core/src/tests/supplementary-prompts.test.ts index 283b3401..cf9e6abe 100644 --- a/packages/core/src/tests/supplementary-prompts.test.ts +++ b/packages/core/src/tests/supplementary-prompts.test.ts @@ -49,7 +49,11 @@ type CapturedRequest = { }; type TestClient = { - chat: { completions: { create: (request: unknown) => Promise } }; + chat: { + completions: { + create: (request: unknown, options?: { signal?: AbortSignal }) => Promise; + }; + }; }; function isSkillMatchingRequest(request: any): boolean { @@ -94,6 +98,65 @@ function createClientMock(options: { }; } +/** + * Client whose second call behaves like a long streaming answer: it emits one + * chunk and then stays open until the request is aborted, which is what + * `steerActiveSession()` does when the user sends new guidance. + */ +function createSteerableClient(options: { + requests: CapturedRequest[]; + firstResponse: unknown; + streamedContent: string; + onStreamStarted: () => void; + followUpResponses: unknown[]; +}): TestClient { + let callIndex = 0; + return { + chat: { + completions: { + create: async (request: unknown, requestOptions?: { signal?: AbortSignal }) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + options.requests.push(request as CapturedRequest); + const index = callIndex; + callIndex += 1; + + if (index === 0) { + return options.firstResponse; + } + if (index === 1) { + const signal = requestOptions?.signal; + const streamedContent = options.streamedContent; + const onStreamStarted = options.onStreamStarted; + return (async function* streamUntilAborted() { + yield { choices: [{ delta: { content: streamedContent } }] }; + onStreamStarted(); + await new Promise((_resolve, reject) => { + const abort = () => { + const error = new Error("Request was aborted."); + error.name = "AbortError"; + reject(error); + }; + if (signal?.aborted) { + abort(); + return; + } + signal?.addEventListener("abort", abort, { once: true }); + }); + yield { choices: [{ delta: {}, finish_reason: "stop" }] }; + })(); + } + + const response = options.followUpResponses.shift(); + assert.ok(response, "expected a queued follow-up response"); + return response; + }, + }, + }, + }; +} + function createTestManager(options: { projectRoot: string; client: TestClient; @@ -370,3 +433,89 @@ test("guidance with an image is injected as a user message that carries the imag assert.equal(stored.length, 1); assert.deepEqual(stored[0]!.meta?.userPrompt?.imageUrls, ["https://example.com/design.png"]); }); + +test("guidance sent while the model is writing cuts the answer short and keeps the turn alive", async () => { + const workspace = createTempDir("deepcode-steer-workspace-"); + const home = createTempDir("deepcode-steer-home-"); + setHomeDir(home); + stubTelemetry(); + + const requests: CapturedRequest[] = []; + let markStreamStarted: () => void = () => {}; + const streamStarted = new Promise((resolve) => { + markStreamStarted = resolve; + }); + const client = createSteerableClient({ + requests, + firstResponse: createChatResponse("initial answer"), + streamedContent: "Here is the first half", + onStreamStarted: () => markStreamStarted(), + followUpResponses: [createChatResponse("revised answer")], + }); + const injected: SessionMessage[] = []; + const manager = createTestManager({ + projectRoot: workspace, + client, + onSupplementaryPromptInjected: (message) => injected.push(message), + }); + + const sessionId = await manager.createSession({ text: "install nginx" }); + requests.length = 0; + + const turn = manager.activateSession(sessionId); + await streamStarted; + + // The user changes direction while the answer is still streaming. + assert.ok(manager.addSupplementaryPrompt(sessionId, { text: "use apache instead" })); + assert.equal(manager.steerActiveSession(), true); + await turn; + + // The turn continued with the guidance instead of ending or failing. + assert.equal(requests.length, 2); + const followUp = requests[1]!.messages; + assert.deepEqual( + followUp.filter((message) => message.role === "user").map((message) => message.content), + ["install nginx", "use apache instead"] + ); + // The follow-up request keeps the earlier answer, the cut-short answer and the + // guidance, in that order. + assert.deepEqual( + followUp.filter((message) => message.role === "assistant").map((message) => message.content), + ["initial answer", "Here is the first half"] + ); + + // What the model had written is kept in the transcript and marked as cut short. + const stored = manager.listSessionMessages(sessionId); + const partial = stored.find((message) => message.content === "Here is the first half"); + assert.equal(partial?.role, "assistant"); + assert.equal(partial?.meta?.interrupted, true); + assert.equal(partial?.visible, true); + const partialIndex = stored.findIndex((message) => message.id === partial?.id); + const guidanceIndex = stored.findIndex((message) => message.meta?.isSupplementary === true); + assert.ok(partialIndex !== -1 && guidanceIndex > partialIndex); + + // The session finished normally, so a steered answer is not an interruption. + assert.equal(manager.getSession(sessionId)?.status, "completed"); + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), 0); + assert.equal(injected.length, 1); +}); + +test("steerActiveSession does nothing when no answer is streaming", async () => { + const workspace = createTempDir("deepcode-steer-idle-workspace-"); + const home = createTempDir("deepcode-steer-idle-home-"); + setHomeDir(home); + stubTelemetry(); + + const requests: CapturedRequest[] = []; + const client = createClientMock({ responses: [createChatResponse("done")], requests }); + const manager = createTestManager({ projectRoot: workspace, client }); + + const sessionId = await manager.createSession({ text: "start" }); + + // No request in flight: the guidance stays queued for the next request boundary, + // and a tool that is executing is therefore never killed by typing. + assert.equal(manager.steerActiveSession(), false); + assert.ok(manager.addSupplementaryPrompt(sessionId, { text: "also add tests" })); + assert.equal(manager.steerActiveSession(), false); + assert.equal(manager.countPendingSupplementaryPrompts(sessionId), 1); +});