feat(tui): add experimental daemon stream path#4266
Conversation
📋 Review SummaryThis PR adds an experimental 🔍 General Feedback
🔴 CriticalNo critical issues identified that block merging. The experimental gating appropriately contains risk. 🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
| }, | ||
| Date.now(), | ||
| ); | ||
| const queuedSubmissions = queuedSubmissionsRef.current.splice(0); |
There was a problem hiding this comment.
[Critical] Queued prompts are replayed concurrently after the daemon connection is established. The loop drains every queued submission and calls submitQueryRef.current(...) without awaiting it, so multiple prompts queued before connect all enter submitQuery with the same stale streamingState === Idle closure. The first call sets the adapter busy; later calls can then fail with A prompt is already in progress, effectively dropping already-accepted user input.
| const queuedSubmissions = queuedSubmissionsRef.current.splice(0); | |
| const queuedSubmissions = queuedSubmissionsRef.current.splice(0); | |
| for (const queuedSubmission of queuedSubmissions) { | |
| await submitQueryRef.current?.( | |
| queuedSubmission.query, | |
| queuedSubmission.submitType, | |
| ); | |
| } |
If queued prompts should remain non-blocking for React effect cleanup, move this into an async drain helper that awaits each turn before sending the next one.
— gpt-5.5 via Qwen Code /review
| } | ||
|
|
||
| const slashCommandResult = trimmedQuery.startsWith('/') | ||
| ? await handleSlashCommand(trimmedQuery) |
There was a problem hiding this comment.
[Critical] The daemon TUI path omits the normal @-command preprocessing that useGeminiStream performs before sending a prompt. Inputs such as explain @packages/cli/src/foo.ts are accepted by the same TUI, but this path sends the literal @... text to the daemon instead of expanding the referenced file/context into the prompt. That silently breaks a core TUI feature under --experimental-daemon-tui and can produce context-free answers while appearing to work.
Please mirror the isAtCommand(...) / handleAtCommand(...) step from the normal stream path before calling adapter.sendPrompt(...), or factor slash/shell/@ preprocessing into a shared helper used by both stream implementations.
— gpt-5.5 via Qwen Code /review
| try { | ||
| const promptText = partListToText(query); | ||
| onDebugMessage(`Sending daemon prompt (${promptText.length} chars)`); | ||
| await adapter.sendPrompt(promptText); |
There was a problem hiding this comment.
[Suggestion] This always flattens PartListUnion into a single string before sending it to the daemon. Slash commands can replace query with structured prompt parts (submit_prompt content), and DaemonTuiAdapter.sendPrompt already accepts ContentBlock[]; flattening here stringifies non-text parts as JSON and loses structured prompt content compared with the normal useGeminiStream path.
Consider preserving structured prompts by converting text parts to daemon ContentBlock[] and only sending a plain string when the query is actually a string, instead of routing every part through partListToText(...).
— gpt-5.5 via Qwen Code /review
| if (typeof result['daemonModel'] === 'string') { | ||
| process.env['QWEN_DAEMON_MODEL'] = result['daemonModel']; | ||
| } | ||
| process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd(); |
There was a problem hiding this comment.
[Critical] process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd() 无条件设置,导致 daemonTuiOptions.ts:39 中 ?? config.getTargetDir() 永远不会被触发。用户通过 --target-dir 指定的目录被静默覆盖。
| process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd(); | |
| // 仅当未设置时才写 env,否则让 daemonTuiOptions 回退到 getTargetDir() | |
| if (!process.env['QWEN_DAEMON_WORKSPACE']) { | |
| process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd(); | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
|
||
| const cancelHandlerRef = useRef<(info?: CancelSubmitInfo) => void>(() => {}); | ||
| const midTurnDrainRef = useRef<(() => string[]) | null>(null); | ||
| const useActiveGeminiStream = daemonTuiEnabled |
There was a problem hiding this comment.
[Critical] const useActiveGeminiStream = daemonTuiEnabled ? useDaemonTuiStream : useGeminiStream 违反 React Hooks 规则。尽管 daemonTuiEnabled 当前稳定(来自只读 env),若未来重构使其在运行时变化,hook 调用顺序将错位导致静默崩溃。
| const useActiveGeminiStream = daemonTuiEnabled | |
| // 始终调用两个 hook,条件选择返回值: | |
| const geminiResult = useGeminiStream(...); | |
| const daemonResult = useDaemonTuiStream(...); | |
| const activeResult = daemonTuiEnabled ? daemonResult : geminiResult; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| text: `Connected to daemon session ${session.sessionId} (${session.workspaceCwd})`, | ||
| }, | ||
| Date.now(), | ||
| ); |
There was a problem hiding this comment.
[Critical] queuedSubmissionsRef.current.splice(0) 在连接成功之前清空队列。若 createDaemonTuiSession 抛异常,已取出消息永久丢失。
| ); | |
| // 将 splice 移到 createDaemonTuiSession 成功之后 | |
| const session = await createDaemonTuiSession(options); | |
| // ... 设置 adapter ... | |
| const queuedSubmissions = queuedSubmissionsRef.current.splice(0); | |
| for (const qs of queuedSubmissions) { | |
| void submitQueryRef.current?.(qs.query, qs.submitType); | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| const promptText = partListToText(query); | ||
| onDebugMessage(`Sending daemon prompt (${promptText.length} chars)`); | ||
| await adapter.sendPrompt(promptText); | ||
| if (sendGenerationRef.current === generation) { |
There was a problem hiding this comment.
[Critical] sendPrompt 异常时 flushPendingItems() 被跳过。daemon 断开时所有已流式传输的文本和工具调用全部丢失。
| if (sendGenerationRef.current === generation) { | |
| catch (error) { | |
| flushPendingItems(); // 先持久化已接收内容 | |
| const message = error instanceof Error ? error.message : String(error); | |
| addItem({ type: MessageType.ERROR, text: message }, Date.now()); | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| token: process.env['QWEN_DAEMON_TOKEN'], | ||
| sessionId: process.env['QWEN_DAEMON_SESSION_ID'], | ||
| sessionScope: readSessionScope(), | ||
| model: process.env['QWEN_DAEMON_MODEL'] ?? config.getModel(), |
There was a problem hiding this comment.
[Suggestion] Daemon 模式跳过 config.initialize(),此处 config.getModel() 可能在未初始化状态下被调用(当 QWEN_DAEMON_MODEL 未设置时)。
| model: process.env['QWEN_DAEMON_MODEL'] ?? config.getModel(), | |
| // 确认 config 状态或提供硬编码 daemon 模式默认值 | |
| model: process.env['QWEN_DAEMON_MODEL'] ?? 'default-model-id', |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| process.env['QWEN_DAEMON_URL'] = result['daemonUrl']; | ||
| } | ||
| if (typeof result['daemonToken'] === 'string') { | ||
| process.env['QWEN_DAEMON_TOKEN'] = result['daemonToken']; |
There was a problem hiding this comment.
[Suggestion] Bearer token 写入 process.env['QWEN_DAEMON_TOKEN'],所有子进程(MCP servers、shell exec)继承此环境变量,token 可能通过子进程日志或 crash dump 泄露。
| process.env['QWEN_DAEMON_TOKEN'] = result['daemonToken']; | |
| // 将 token 保留在内存中的 DaemonTuiRuntimeOptions 对象,通过函数参数传递 |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| return; | ||
| case 'model_switched': | ||
| onDebugMessage(`Daemon model switched to ${update.modelId}`); | ||
| return; |
There was a problem hiding this comment.
[Suggestion] Daemon 断开后 adapterRef.current 未清除,useEffect 不会重新连接。后续提交全部失败,必须退出 CLI 重启。
| return; | |
| // 在 disconnected 处理中重置 adapterRef 并触发重连 | |
| adapterRef.current = null; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| async (_newApprovalMode: ApprovalMode) => {}, | ||
| [], | ||
| ); | ||
|
|
There was a problem hiding this comment.
[Suggestion] handleApprovalModeChange 是空操作 async () => {}。UI 切换审批模式显示成功,但 daemon 后端未改变。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| logger, | ||
| onDebugMessage, | ||
| setPending, | ||
| streamingState, |
There was a problem hiding this comment.
[Suggestion] adapterRef.current 为 null 时 cancelOngoingRequest 直接 return 而不重置 streamingState,UI 可能永久卡在 "Responding"。
| streamingState, | |
| if (!adapter) { | |
| setStreamingState(StreamingState.Idle); | |
| return; | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| }); | ||
|
|
||
| if (options.sessionId) { | ||
| return await DaemonSessionClient.load(client, options.sessionId, { |
There was a problem hiding this comment.
[Suggestion] Token 回退使用 QWEN_SERVER_TOKEN,与 config.ts 设置的 QWEN_DAEMON_TOKEN 变量名不一致。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
c6c6be2 to
8f04fcb
Compare
| if (slashCommandResult.type === 'handled') { | ||
| return; | ||
| } | ||
| if (slashCommandResult.type === 'submit_prompt') { |
There was a problem hiding this comment.
[Critical] submit_prompt slash commands lose their onComplete callback in the daemon TUI path. The normal stream stores slashCommandResult.onComplete and invokes it after the model turn completes; here the code only replaces query with slashCommandResult.content, so commands such as /dream submit successfully but never record their completion state.
Mirror the normal stream behavior by keeping the callback when handling submit_prompt, then invoking and clearing it after adapter.sendPrompt(...) completes and pending items have been flushed.
— gpt-5.5 via Qwen Code /review
| handleSlashCommand: ( | ||
| cmd: PartListUnion, | ||
| ) => Promise<SlashCommandProcessorResult | false>, | ||
| _shellModeActive: boolean, |
There was a problem hiding this comment.
[Critical] _shellModeActive is ignored by the daemon stream hook. In the normal useGeminiStream path, shell mode routes submitted text through handleShellCommand(...) and stops it from reaching the model; in this path the same shell-mode input falls through and is sent to the daemon as a prompt. Users who enter shell mode can therefore leak command text to the model instead of executing it locally.
Wire the daemon path to the same shell command processing behavior as useGeminiStream, or disable shell mode while --experimental-daemon-tui is active so the UI cannot enter a mode whose submissions are handled incorrectly.
— gpt-5.5 via Qwen Code /review
| case 'model_switched': | ||
| onDebugMessage(`Daemon model switched to ${update.modelId}`); | ||
| return; | ||
| case 'disconnected': |
There was a problem hiding this comment.
[Critical] The disconnected handler in handleUpdate resets streamingState to Idle and adds an error item, but never calls flushPendingItems(). All streamed model output and tool-call updates accumulated in pendingItemsRef before the disconnect are silently discarded.
This is distinct from the sendPrompt error path (line 418) — the disconnected case is triggered from the event pump when the SSE stream errors, not from the submitQuery RPC error path.
| case 'disconnected': | |
| case 'disconnected': | |
| flushPendingItems(); | |
| setStreamingState(StreamingState.Idle); | |
| setIsReceivingContent(false); | |
| addItem( | |
| { | |
| type: MessageType.ERROR, | |
| text: `Daemon disconnected: ${update.reason}`, | |
| }, | |
| Date.now(), | |
| ); | |
| return; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
Pushed a follow-up commit to align this draft with the latest daemon architecture boundary:
Validation after the follow-up: Generated by GPT-5 Codex |
4f03a11 to
faeca7d
Compare
|
Follow-up CI fix for this draft: the previous run failed because I amended the branch to build
All passed. Generated by GPT-5 Codex |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
wenshao
left a comment
There was a problem hiding this comment.
No new review findings in the latest changes. Downgraded from Approve to Comment: CI failing: Test (ubuntu-latest, Node 22.x). — gpt-5.5 via Qwen Code /review
| onDebugMessage(`Daemon model switched to ${update.modelId}`); | ||
| return; | ||
| case 'disconnected': | ||
| setStreamingState(StreamingState.Idle); |
There was a problem hiding this comment.
[Critical] The disconnected handler in handleUpdate resets streamingState to Idle and posts an error, but never calls flushPendingItems(). Any model response chunks already received via SSE and accumulated in pendingItemsRef.current are silently discarded. The user sees only the disconnect error, not the partial output that could contain diagnostic clues.
| setStreamingState(StreamingState.Idle); | |
| case 'disconnected': | |
| flushPendingItems(); | |
| setStreamingState(StreamingState.Idle); | |
| setIsReceivingContent(false); | |
| addItem( | |
| { | |
| type: MessageType.ERROR, | |
| text: `Daemon disconnected: ${update.reason}`, | |
| }, | |
| Date.now(), | |
| ); | |
| return; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
Architecture update after the 2026-05-19 decision: native local TUI should remain on the direct runtime / streamJson / Ink path long-term. The daemon path should prioritize web chat + web terminal, with shared render-core extraction so native TUI and web terminal can share state/rendering without forcing native TUI through localhost HTTP. This draft is valuable as an experiment, but it should not move toward merge as a production TUI replacement path. Recommendation: close/defer it, and salvage only reusable source-adapter / view-model / terminal-render-core pieces for the web-terminal/render-core direction. Generated by GPT-5 Codex |
| await logger?.logMessage(MessageSenderType.USER, trimmedQuery); | ||
| } | ||
|
|
||
| const slashCommandResult = trimmedQuery.startsWith('/') |
There was a problem hiding this comment.
[Critical] This daemon stream path checks trimmedQuery.startsWith('/') instead of using the normal slash-command classifier. The regular TUI deliberately excludes absolute file paths and comment-like inputs from slash-command handling, so inputs such as /Users/me/project/src/index.ts or /tmp/file.txt can be routed through handleSlashCommand here instead of being sent to the model as user text. Please import/use isSlashCommand(trimmedQuery) so daemon mode preserves the same input semantics as the normal stream path.
— gpt-5.5 via Qwen Code /review
| }; | ||
| }, [addItem, handleUpdate, runtimeOptions]); | ||
|
|
||
| const submitQuery = useCallback( |
There was a problem hiding this comment.
[Suggestion] This PR adds a new daemon-backed stream hook and new config/AppContainer wiring, but the diff does not add tests for those paths. The untested surface includes slash-command classification in useDaemonTuiStream, queued initial-prompt replay, the daemon env/options mapping, and AppContainer's daemon-mode initialization/switching behavior. Please add focused unit tests for these paths so the experimental mode does not regress silently as the normal TUI code evolves.
— gpt-5.5 via Qwen Code /review
| 'Commands: /quit, /cancel, /model <id>, /approve <id> <option>, /reject <id>', | ||
| ); | ||
| for (;;) { | ||
| const line = (await rl.question('qwen-daemon> ')).trim(); |
There was a problem hiding this comment.
[Critical] No error handling around await adapter.* calls in the interactive REPL loop. Any transient RPC error (timeout, bad model name, network blip) on sendPrompt, cancel, setModel, approvePermission, or rejectPermission propagates to the outer try/finally, which calls process.exit(1). A single flaky command kills the entire session.
| const line = (await rl.question('qwen-daemon> ')).trim(); | |
| for (;;) { | |
| const line = (await rl.question('qwen-daemon> ')).trim(); | |
| if (!line) { | |
| continue; | |
| } | |
| if (line === '/quit' || line === '/exit') { | |
| return; | |
| } | |
| try { | |
| if (line === '/cancel') { | |
| await adapter.cancel(); | |
| continue; | |
| } | |
| if (line.startsWith('/model ')) { | |
| await adapter.setModel(line.slice('/model '.length).trim()); | |
| continue; | |
| } | |
| if (line.startsWith('/approve ')) { | |
| const [, requestId, optionId] = line.split(/\s+/, 3); | |
| if (!requestId || !optionId) { | |
| writeLine('usage: /approve <requestId> <optionId>'); | |
| continue; | |
| } | |
| await adapter.approvePermission(requestId, optionId); | |
| continue; | |
| } | |
| if (line.startsWith('/reject ')) { | |
| const [, requestId] = line.split(/\s+/, 2); | |
| if (!requestId) { | |
| writeLine('usage: /reject <requestId>'); | |
| continue; | |
| } | |
| await adapter.rejectPermission(requestId); | |
| continue; | |
| } | |
| await adapter.sendPrompt(line); | |
| } catch (err) { | |
| writeLine(`Error: ${err instanceof Error ? err.message : String(err)}`); | |
| } | |
| } |
— qwen-latest-series-invite-beta-v28 via Qwen Code /review
| } | ||
|
|
||
| if (submitType !== SendMessageType.Notification) { | ||
| await logger?.logMessage(MessageSenderType.USER, trimmedQuery); |
There was a problem hiding this comment.
[Critical] retryLastPrompt calls submitQuery with SendMessageType.Retry, but unlike useGeminiStream (which explicitly bypasses prepareQueryForGemini for Retry), this path unconditionally logs a duplicate USER message (line 404) and inserts a duplicate USER history item (line 431). Every retry shows the user's prompt twice in the conversation and records it twice in the chat log.
Guard both locations to skip Retry:
| await logger?.logMessage(MessageSenderType.USER, trimmedQuery); | |
| if (submitType !== SendMessageType.Notification && submitType !== SendMessageType.Retry) { | |
| await logger?.logMessage(MessageSenderType.USER, trimmedQuery); | |
| } |
And at line 431:
| await logger?.logMessage(MessageSenderType.USER, trimmedQuery); | |
| } else if (submitType !== SendMessageType.Notification && submitType !== SendMessageType.Retry) { | |
| addItem({ type: MessageType.USER, text: trimmedQuery }, Date.now()); | |
| } |
— qwen-latest-series-invite-beta-v28 via Qwen Code /review
| const { DaemonClient, DaemonSessionClient } = await import('@qwen-code/sdk'); | ||
| const client = new DaemonClient({ | ||
| baseUrl: options.daemonUrl, | ||
| token: options.token ?? process.env['QWEN_SERVER_TOKEN'], |
There was a problem hiding this comment.
[Critical] Token fallback uses ?? which only falls through on null/undefined. When QWEN_DAEMON_TOKEN is set to empty string, options.token is '' (not nullish), so ?? does NOT fall through to QWEN_SERVER_TOKEN. Auth fails with a confusing empty-token error.
Also, the token fallback logic is split across daemonTuiOptions.ts:36 (reads only QWEN_DAEMON_TOKEN) and here (falls back to QWEN_SERVER_TOKEN). Consolidate in one place:
| token: options.token ?? process.env['QWEN_SERVER_TOKEN'], | |
| token: options.token || process.env['QWEN_SERVER_TOKEN'], |
— qwen-latest-series-invite-beta-v28 via Qwen Code /review
| return () => { | ||
| disposed = true; | ||
| adapterRef.current = null; | ||
| void adapter?.stop(); |
There was a problem hiding this comment.
[Suggestion] adapter.stop() aborts the SSE event pump but never calls this.session.close(). The daemon session is leaked on the server — every TUI unmount, effect re-run, or CLI exit leaves an orphaned session. DaemonSessionClient.close() exists but is never invoked from any code path introduced by this PR.
Consider closing the session before stopping the adapter:
| void adapter?.stop(); | |
| await adapter.close(); // closes daemon session | |
| void adapter?.stop(); |
Or add session close to DaemonTuiAdapter.stop() itself.
— qwen-latest-series-invite-beta-v28 via Qwen Code /review
|
@chiga0 这个 PR 的定位需要明确:`--experimental-daemon-tui` 永远是 opt-in advanced flag,不进入 default migration。 理由:本地单用户 TUI 必须保持 in-process direct call,永远不走网络。详 #3803 comment 4483031818 + #4175 comment 4483033542。 请在 PR description 顶部加:
PR title 建议改为: PR 代码本身没问题,可以继续开发到 ready-for-review。只是定位需要在 description / title 明示,让未来 reviewer / contributor 不会误以为这是 default migration 的 stepping stone。 |
|
Closing this draft per the latest daemon roadmap decision: native local TUI remains a long-term direct runtime / streamJson / Ink path and should not default-migrate to daemon HTTP/SSE. Reusable render/view-model ideas should be mined later for the remote web terminal + shared render-core direction. Generated by GPT-5 Codex |
Co-authored-by: Agus Zubiaga <agus@zed.dev> Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com> Co-authored-by: mkorwel <matt.korwel@gmail.com>
Summary
--experimental-daemon-tuipath for the normal TUI entrypoint. When enabled, the TUI creates or attaches to aqwen servedaemon session, submits prompts through the daemon session client, and renders daemon SSE updates through existing TUI history/pending-item components instead of the text-onlydaemon-tuiharness.DaemonSessionClient/DaemonTuiAdapterand projects them into the Ink UI; it is not a PTY proxy and does not add a second runtime path.Validation
只回答 OK> 只回答 OK, streamed through the normal pending assistant UI, and displayedOKas assistant output. No[gemini_thought_content]raw event prefixes were printed.npm run typecheck --workspace=packages/cli,npm run build --workspace=packages/cli, andgit diff --checkpassed after the architecture-boundary follow-up commit.qwen servewith the built CLI, then run the second command above from the same workspace.Scope / Risk
useGeminiStream.--experimental-daemon-tui; without that flag the existing TUI continues to calluseGeminiStream.Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
Related to #3803 and #4175.