diff --git a/README-en.md b/README-en.md index 98f0134e..aa134727 100644 --- a/README-en.md +++ b/README-en.md @@ -88,11 +88,23 @@ 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. 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 - `deepseek-flash` (Recommended) diff --git a/README.md b/README.md index 75b91e26..24bb6d85 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,19 @@ Skills 会按以下优先级扫描: | 按键 | 操作 | |---------------|--------------------| | `Enter` | 发送消息 | +| `Enter`(忙碌时) | 作为补充指引发给正在执行的这一轮 | | `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | | `Ctrl+V` | 从剪贴板粘贴图片 | | `Esc` | 中断当前模型回复 | +| `Backspace` | 输入框为空时,移除最后一条待注入的指引 | | 连续 `Ctrl+D` | 退出 | +AI 正在回复时,`Enter` 不再被拒绝,也不必等到本轮结束:消息会作为补充指引,在本轮下一次 +LLM 调用前作为 user 消息追加到对话中。模型因此能读到它,并结合已完成的工作修改甚至推翻之前的 +指令。如果模型正在输出回答,该回答会被截断,已生成的内容仍保留在对话里;可用 `steerMode: "queue"` +改为等到下次请求边界再注入。最多可排队 10 条;输入框为空时按 `Backspace` 可移除最后一条。 +正在执行的命令不会被中断,按 `Esc` 仍会立即中断本轮,斜杠命令仍需等待本轮结束。 + ## 支持的模型 - `deepseek-flash`(推荐使用) 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/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/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/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..88bcf635 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") { @@ -81,6 +88,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return {seg.body}; }) : null} + {message.meta?.interrupted ? — superseded by your guidance : null} ); @@ -169,7 +177,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..5ea2f78a 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,32 @@ 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, + }); + // 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] + [handlePrompt, projectRoot, sessionManager] ); const handlePlanImplementationChoice = useCallback( @@ -584,6 +631,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 +883,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 +1114,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 */} 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 5fb2c01f..cb205aee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,13 +35,14 @@ export type { PermissionDefaultMode, McpServerConfig, ReasoningEffort, + SteerMode, StatusLineSettings, ResolvedStatusLineSettings, StatusLineProviderConfig, } from "./settings"; // Session -export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session"; +export { SessionManager, getProjectCode, getCompactPromptTokenThreshold, MAX_SUPPLEMENTARY_PROMPTS } from "./session"; export type { SessionMessage, SessionEntry, @@ -58,6 +59,7 @@ export type { LlmStreamProgress, LlmRetryEvent, SessionManagerOptions, + SupplementaryPrompt, } from "./session"; // Prompt utilities diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index cda8f8fb..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"; @@ -314,6 +315,10 @@ export type MessageMeta = { skillCatalog?: Array<{ name: string; description: string }>; permissions?: MessageToolPermission[]; 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 = { @@ -361,6 +366,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 +403,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 +455,19 @@ 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; + /** 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; @@ -458,6 +487,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); @@ -699,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; @@ -946,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(), @@ -1452,6 +1493,182 @@ ${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; + } + + /** + * 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; @@ -1768,6 +1985,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, @@ -1798,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, @@ -1808,7 +2034,7 @@ ${agentInstructions} tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), ...thinkingOptions, }, - { signal: sessionController.signal }, + { signal: steer.signal }, sessionId, { enabled: debugLogEnabled, @@ -1821,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; } @@ -1835,6 +2067,8 @@ ${agentInstructions} sessionController.signal ); response = await request(); + } finally { + steer.release(); } const message = response.choices?.[0]?.message; @@ -1925,6 +2159,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 +2899,9 @@ ${agentInstructions} controller.abort(); } 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 new file mode 100644 index 00000000..cf9e6abe --- /dev/null +++ b/packages/core/src/tests/supplementary-prompts.test.ts @@ -0,0 +1,521 @@ +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, options?: { signal?: AbortSignal }) => 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; + }, + }, + }, + }; +} + +/** + * 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; + 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"]); +}); + +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); +});