From c806b0b34606d0e2304ff12f4e75d47e521c3116 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:48:16 +0800 Subject: [PATCH 01/15] fix: improve queue steering interactions Add one-shot inverse queue/steer submission, allow steering any queued item, and expand each row's safe drag target. Model: gpt-5 --- .agents/docs/sessions-live-status.md | 6 +- .../2026-09-13-queue-steer-controls.md | 52 ++++++++++++++ .../2026-09-13-queue-steer-controls.zh.md | 43 +++++++++++ locales/en.json | 1 + locales/zh_CN.json | 1 + .../sessions/message-queue/AGENTS.md | 5 ++ .../sessions/message-queue/index.ts | 6 +- .../message-queue/message-queue-display.tsx | 1 - .../message-queue/message-queue-row.tsx | 72 +++++++++---------- .../message-queue/queued-message-steer.ts | 43 +++++++++++ .../sessions/session-chat-input-area.tsx | 35 ++++++++- .../sessions/session-chat-interface.tsx | 51 +++++++++---- .../sessions/session-message-submit-route.ts | 9 ++- .../components/src/lib/commands/built-ins.ts | 6 ++ .../components/src/lib/commands/shortcuts.ts | 2 + .../tests/commands-built-ins.test.ts | 6 ++ .../tests/message-queue-row-editing.test.tsx | 50 +++++++++++-- .../tests/queued-message-steer.test.ts | 62 +++++++++++++++- .../session-chat-input-submission.test.tsx | 17 ++++- .../session-message-submit-route.test.ts | 20 ++++++ specs/message-queue-interactions.md | 46 ++++++++++++ specs/message-queue-interactions.zh.md | 37 ++++++++++ 22 files changed, 505 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md create mode 100644 .agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md create mode 100644 specs/message-queue-interactions.md create mode 100644 specs/message-queue-interactions.zh.md diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index 0b00bcbd9..d8bc60f78 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -43,8 +43,10 @@ this page is the full text of the rules summarised there. must queue in that state (even when the preference is guide; steering requires positive live prompt activity), because queue promotion is safe for both a live turn and a stale transcript while direct dispatch can create a second accepted - turn. This barrier affects routing only; it must not relight Working UI or enable - Stop. That pre-start label is additionally suppressed whenever the + turn. An explicit inverse submission swaps queue and guide for that submission + only, then applies the same activity and ordering gates; it never changes the + stored preference. This barrier affects routing only; it must not relight Working + UI or enable Stop. That pre-start label is additionally suppressed whenever the status chip has an active connection/machine problem (`statusStripState != null`: browser offline, machine removed or offline) — the chip owns that story, and "Starting…" next to "machine offline" is a contradiction. `isSessionWorking` diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md new file mode 100644 index 000000000..260e331fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md @@ -0,0 +1,52 @@ +# Queue inversion and direct manipulation + +Status: implemented +Translation: current + +[中文](2026-09-13-queue-steer-controls.zh.md) + +## Abstract + +Queue and Steer previously required changing a persistent preference, later queued +items hid Steer, and reordering started only from the small leading handle. The adopted +interaction adds a one-shot inverse submission command, exposes Steer on every row, and +uses the row's message content as its drag target. Providers without acknowledged native +steering preserve their compatibility path by moving a selected later item to the head +before cancellation; a failed reorder leaves the active turn untouched. + +## Decision + +- Register `session.sendWithInverseQueueBehavior` in the command system with + `Mod+Shift+Enter` as its composer-focused default. The composer sends an explicit + per-submission option; the routing resolver reverses the effective preference without + writing settings or bypassing activity guards. +- Show Steer for every row whenever the session-level action is available. Native steer + continues to materialize and remove the selected item by identity. The compatibility + path retains one queue consumer: it reorders the selection to the head, then uses the + existing cancel-and-promote flow. +- Make the leading number and message body a single pointer and keyboard drag activator. + Keep action buttons outside it, and disable it while the row editor owns interaction. + +## Alternatives and trade-offs + +Keeping Steer on the first row would preserve the old fallback assumption but require +users to perform a separate reorder. Sending a selected compatibility item through a new +direct-dispatch path was rejected because it would duplicate the daemon's queue promotion +ownership and create a second ordering boundary. Making the complete row draggable was +also rejected because Steer, edit, and remove would become accidental drag starters. + +## Verification and limits + +- Routing tests cover Queue → Steer and Steer → Queue inversion while a prompt is live. +- Command registry tests cover the default binding in web and Electron environments. +- Queue component tests cover Steer on a later row and verify that message content, but + not action buttons, belongs to the drag activator. Queue-steer tests cover later-item + preparation, an already-first item, and a stale selection. +- Component tests use synthetic pointer state. Physical touch dragging and a full + provider-backed steer run were not exercised. + +## References + +- [Message queue interaction Spec](../../../../specs/message-queue-interactions.md) +- [Queue scope](../../../../packages/components/src/components/sessions/message-queue/AGENTS.md) +- [Submission routing](../../../docs/sessions-live-status.md) diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md new file mode 100644 index 000000000..91f468068 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md @@ -0,0 +1,43 @@ +# 队列反转与直接操作 + +Status: implemented +Translation: current + +[English](2026-09-13-queue-steer-controls.md) + +## 摘要 + +此前若要在“排队”和“引导”间切换,必须修改持久设置;后续队列项不显示“引导”,重排也只能 +从狭小的左侧把手开始。本次采用一次性反转提交命令、让每行都显示“引导”,并把消息内容区 +作为拖动区域。对于没有原生带确认 steer 的 Provider,兼容路径会在取消前先把所选后续项移到 +队首;如果重排失败,则不触碰当前 turn。 + +## 决策 + +- 在命令系统注册 `session.sendWithInverseQueueBehavior`,默认仅在输入框聚焦时由 + `Mod+Shift+Enter` 触发。输入框传递显式的单次提交选项;路由器只反转本次有效偏好, + 不写入设置,也不绕过活动状态保护。 +- 会话允许引导时,每一行都显示“引导”。原生 steer 继续按身份生成历史并移除所选项; + 兼容路径仍只有一个队列消费者:先把所选项移到队首,再沿用取消与队列提升流程。 +- 左侧序号和消息正文合并为一个支持鼠标及键盘的拖动区域。操作按钮保持在区域外;编辑器 + 接管交互时禁用该行拖动。 + +## 备选方案与取舍 + +只允许队首“引导”可维持旧兼容路径假设,但用户仍必须额外重排。没有采用新的直接发送路径 +来发送兼容模式下的所选项,因为这会复制 daemon 的队列提升职责并增加第二个顺序边界。 +也没有让整行都可拖动,因为那会使“引导”、“编辑”和“移除”成为意外的拖动起点。 + +## 验证与边界 + +- 路由测试覆盖 prompt 活动时“排队 → 引导”和“引导 → 排队”的反转。 +- 命令注册测试覆盖 Web 与 Electron 环境中的默认快捷键。 +- 队列组件测试覆盖对后续项执行“引导”,并验证消息内容属于拖动区域而操作按钮不属于。 + 队列 steer 测试覆盖后续项准备、已在队首的项以及过期选择。 +- 组件测试使用合成指针状态;未验证物理触摸拖动和完整的 Provider-backed steer 流程。 + +## 参考 + +- [消息队列交互 Spec](../../../../specs/message-queue-interactions.zh.md) +- [队列作用域](../../../../packages/components/src/components/sessions/message-queue/AGENTS.md) +- [提交路由](../../../docs/sessions-live-status.md) diff --git a/locales/en.json b/locales/en.json index 49455ec8a..d4b547342 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3450,6 +3450,7 @@ "commands.session.toggleCurrentPinned": "Toggle Current Chat Pinned", "commands.session.searchCurrent": "Find in Current Chat", "commands.session.focusInput": "Focus Current Input", + "commands.session.sendWithInverseQueueBehavior": "Send with Opposite Queue/Steer Behavior", "commands.session.saveCurrentFile": "Save Current File", "commands.session.toggleExplorerSidebar": "Toggle Files and Changes Sidebar", "commands.session.copyCurrentBranch": "Copy Current Branch", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index e4bf302f1..0e7646135 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -3450,6 +3450,7 @@ "commands.session.toggleCurrentPinned": "切换当前对话置顶", "commands.session.searchCurrent": "在当前对话中查找", "commands.session.focusInput": "聚焦当前输入框", + "commands.session.sendWithInverseQueueBehavior": "以相反的排队/引导方式发送", "commands.session.saveCurrentFile": "保存当前文件", "commands.session.toggleExplorerSidebar": "切换文件与更改侧边栏", "commands.session.copyCurrentBranch": "复制当前分支", diff --git a/packages/components/src/components/sessions/message-queue/AGENTS.md b/packages/components/src/components/sessions/message-queue/AGENTS.md index 7ed62a025..e9d1cd7a5 100644 --- a/packages/components/src/components/sessions/message-queue/AGENTS.md +++ b/packages/components/src/components/sessions/message-queue/AGENTS.md @@ -14,5 +14,10 @@ authoritative ACP capability cache advertises it. Never infer steering support from built-in/custom config type or agent identity; unsupported and stale cache entries retain the interrupt-and-send fallback. +Every queued row offers Steer while an active turn can accept it. The compatibility +fallback must move a selected later row to the queue head before interrupting, and +must not interrupt if that reorder fails. A row's number and message body are one +drag activator; its Steer, edit, and remove controls stay outside that activator. + The queue intentionally stays OUT of the composer info bar ([.agents/docs/sessions-info-bar.md](../../../../../../.agents/docs/sessions-info-bar.md)). diff --git a/packages/components/src/components/sessions/message-queue/index.ts b/packages/components/src/components/sessions/message-queue/index.ts index ae0123174..4f924c2df 100644 --- a/packages/components/src/components/sessions/message-queue/index.ts +++ b/packages/components/src/components/sessions/message-queue/index.ts @@ -2,7 +2,11 @@ export { MessageQueueDisplay } from './message-queue-display'; export type { MessageQueueDisplayProps } from './message-queue-display'; export { MessageQueueRow } from './message-queue-row'; export type { MessageQueueRowProps } from './message-queue-row'; -export { shouldRequestNativeQueueSteer } from './queued-message-steer'; +export { + resolveFallbackQueueSteerPreparation, + steerQueuedMessageWithFallback, + shouldRequestNativeQueueSteer, +} from './queued-message-steer'; export { QueuedImagePreview } from './queued-image-preview'; export type { QueuedImageBlock } from './queued-image-preview'; export { diff --git a/packages/components/src/components/sessions/message-queue/message-queue-display.tsx b/packages/components/src/components/sessions/message-queue/message-queue-display.tsx index 99446078f..00bffbe28 100644 --- a/packages/components/src/components/sessions/message-queue/message-queue-display.tsx +++ b/packages/components/src/components/sessions/message-queue/message-queue-display.tsx @@ -156,7 +156,6 @@ export function MessageQueueDisplay({ sessionId={sessionId} item={item} index={index} - isFirst={index === 0} showSteerAction={showSteerAction} canReorder={canReorder} isEditing={isEditing} diff --git a/packages/components/src/components/sessions/message-queue/message-queue-row.tsx b/packages/components/src/components/sessions/message-queue/message-queue-row.tsx index f32cf818b..5bee9d6d8 100644 --- a/packages/components/src/components/sessions/message-queue/message-queue-row.tsx +++ b/packages/components/src/components/sessions/message-queue/message-queue-row.tsx @@ -17,7 +17,6 @@ export type MessageQueueRowProps = { sessionId: SessionId; item: MessageQueueItem; index: number; - isFirst: boolean; showSteerAction: boolean; canReorder: boolean; isEditing: boolean; @@ -37,8 +36,10 @@ type EditCommitProps = { }; export function MessageQueueRow(props: MessageQueueRowProps) { + const { t } = useTranslation(); const { item, canReorder, isEditing, isPending, editValue, onCancelEdit, onSaveEdit } = props; const sortable = useSortable({ id: item.$cid, disabled: !canReorder || isEditing }); + const dragEnabled = canReorder && !isEditing; const constrainedTransform = sortable.transform ? { ...sortable.transform, x: 0, scaleX: 1, scaleY: 1 } : null; @@ -95,22 +96,28 @@ export function MessageQueueRow(props: MessageQueueRowProps) { isEditing && 'bg-background/60' )} > - - +
+ + +
); } -function LeadingHandle({ - index, - canReorder, - isEditing, - sortable, -}: MessageQueueRowProps & { sortable: ReturnType }) { - const { t } = useTranslation(); - const label = t('sessions.messageQueue.dragToReorder', 'Drag to reorder'); - +function LeadingHandle({ index, canReorder, isEditing }: MessageQueueRowProps) { if (!canReorder || isEditing) { return (
- - - - {label} - + ); } @@ -288,7 +284,7 @@ function RowBody(props: MessageQueueRowProps & EditCommitProps) { function RowActions(props: MessageQueueRowProps) { const { t } = useTranslation(); - const { item, isFirst, showSteerAction, isEditing, onStartEdit, onRemove, onSteer } = props; + const { item, showSteerAction, isEditing, onStartEdit, onRemove, onSteer } = props; // In edit mode the textarea owns the row: it carries its own confirm button, so we // render no row-level actions that would compete for the click mid-edit. @@ -298,7 +294,7 @@ function RowActions(props: MessageQueueRowProps) { return (
- {isFirst && showSteerAction ? ( + {showSteerAction ? ( Promise; + interrupt: () => Promise; +}): Promise<'steered' | 'missing' | 'reorder_failed'> { + const preparation = resolveFallbackQueueSteerPreparation(queueItemCids, selectedCid); + if (preparation.type === 'missing') return 'missing'; + if (preparation.type === 'reorder') { + try { + await reorder(preparation.activeCid, preparation.overCid); + } catch { + return 'reorder_failed'; + } + } + await interrupt(); + return 'steered'; +} + export function shouldRequestNativeQueueSteer( authority: AcpCapabilityAuthority, capability: Pick | undefined diff --git a/packages/components/src/components/sessions/session-chat-input-area.tsx b/packages/components/src/components/sessions/session-chat-input-area.tsx index e6727d16a..36c87b01d 100644 --- a/packages/components/src/components/sessions/session-chat-input-area.tsx +++ b/packages/components/src/components/sessions/session-chat-input-area.tsx @@ -145,6 +145,7 @@ import { useCodeCollabRequestedRole } from '@/hooks/use-code-collab-requested-ro import { splitImageAndFileAttachments } from '@/lib/file-drop'; import { SessionUsagePopover } from './session-usage-popover'; import type { MachineRateLimits } from '@/lib/session-usage'; +import { getCommandKeybindings, useCommand } from '@/lib/commands'; const sessionDraftsCache = new Map(); @@ -441,7 +442,8 @@ export interface SessionChatInputAreaProps { onConfigOptionChange?: (configId: string, value: AcpConfigOptionValue) => void; onSendMessage: ( inputBlocks: SessionInputBlock[], - agentRole: SessionTurnAgentRoleSelection + agentRole: SessionTurnAgentRoleSelection, + options?: SessionChatInputSubmitOptions ) => Promise; onStop: () => void | Promise; onRemoveQueueItem: (itemId: string) => Promise; @@ -471,6 +473,10 @@ export interface SessionChatInputAreaProps { export type SessionTurnAgentRoleSelection = ComposerTurnAgentRoleSelection; +export type SessionChatInputSubmitOptions = { + invertQueuedBehavior?: boolean; +}; + export type SessionChatInputAreaHandle = { setInputText: (text: string) => void; focusInput: () => void; @@ -1756,7 +1762,10 @@ export const SessionChatInputArea = memo( [pastedTextDrafts, session.id, updatePastedTextDraftsForSession] ); + const invertQueuedBehaviorForNextSubmitRef = useRef(false); const sendMessage = useCallback(async () => { + const invertQueuedBehavior = invertQueuedBehaviorForNextSubmitRef.current; + invertQueuedBehaviorForNextSubmitRef.current = false; if (freeTurnLimitNotice && freeTurnLimitNotice.current >= freeTurnLimitNotice.limit) { capturePostHogEvent(postHog, 'session/input_blocked', { reason: 'free_session_turn_limit_reached', @@ -1900,7 +1909,11 @@ export const SessionChatInputArea = memo( const submission = beginSubmission({ dismissKeyboard: usesMobileKeyboardAction }); if (!submission) return; try { - const accepted = await onSendMessage(inputBlocks, agentRoleTurnSelectionRef.current); + const accepted = await onSendMessage( + inputBlocks, + agentRoleTurnSelectionRef.current, + invertQueuedBehavior ? { invertQueuedBehavior: true } : undefined + ); if (accepted) { if (submission.isCurrent()) { clearInput(); @@ -1999,6 +2012,24 @@ export const SessionChatInputArea = memo( isExternalHistoryRefreshing || durableAgentRoleReady === false || Boolean(freeTurnLimitNotice && freeTurnLimitNotice.current >= freeTurnLimitNotice.limit); + useCommand( + { + id: 'session.sendWithInverseQueueBehavior', + title: t( + 'commands.session.sendWithInverseQueueBehavior', + 'Send with Opposite Queue/Steer Behavior' + ), + category: 'Session', + keybindings: getCommandKeybindings('session.sendWithInverseQueueBehavior'), + allowInTextInput: true, + when: () => hasSendableContent && !isSendActionDisabled, + run: () => { + invertQueuedBehaviorForNextSubmitRef.current = true; + void sendMessage(); + }, + }, + commandsEnabled + ); const attachmentAddEnabled = !isArchived; const sessionLocalFileSource = useMemo( () => diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index b303a575b..de922c51e 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -66,7 +66,11 @@ import { type SessionTurnAgentRoleSelection, } from './session-chat-input-area'; import { useSessionMcpSelection } from '@/hooks/use-session-mcp-selection'; -import { MessageQueueDisplay, shouldRequestNativeQueueSteer } from './message-queue'; +import { + MessageQueueDisplay, + shouldRequestNativeQueueSteer, + steerQueuedMessageWithFallback, +} from './message-queue'; import { useTranslation } from 'react-i18next'; import { useRouter } from '@tanstack/react-router'; import { toast } from 'sonner'; @@ -1907,6 +1911,7 @@ export type SessionChatInterfaceHandle = { export type DispatchInputBlocksOptions = { forceQueue?: boolean; forceDirect?: boolean; + invertQueuedBehavior?: boolean; modeIdOverride?: string | null; modelIdOverride?: string | null; configOptionValuesOverride?: Record; @@ -3951,6 +3956,7 @@ export const SessionChatInterface = memo( const submitRoute = resolveSessionMessageSubmitRoute({ forceDirect, forceQueue: options?.forceQueue === true, + invertQueuedBehavior: options?.invertQueuedBehavior === true, isPromptBusy: isAgentBusy, hasUnfinishedAssistantTurn: activeAssistantTurnId != null, queuedMessageBehavior, @@ -3977,6 +3983,7 @@ export const SessionChatInterface = memo( ...inputSummary, force_queue: Boolean(options?.forceQueue), force_direct: forceDirect, + invert_queued_behavior: Boolean(options?.invertQueuedBehavior), submit_route: submitRoute.type, is_agent_busy: isAgentBusy, mode_id: turnModeId ?? null, @@ -4100,9 +4107,10 @@ export const SessionChatInterface = memo( const handleSendMessage = useCallback( async ( inputBlocks: SessionInputBlock[], - agentRole?: SessionTurnAgentRoleSelection + agentRole?: SessionTurnAgentRoleSelection, + options?: Pick ): Promise => { - return await dispatchInputBlocks(inputBlocks, { agentRole }); + return await dispatchInputBlocks(inputBlocks, { agentRole, ...options }); }, [dispatchInputBlocks] ); @@ -5279,17 +5287,6 @@ export const SessionChatInterface = memo( capabilityAuthority, queueSteerCapability ); - const handleSteerQueuedMessage = useCallback( - async (item: MessageQueueItem) => { - if (shouldUseNativeQueueSteer) { - await handleNativeSteerQueuedMessage(item); - return; - } - await handleInterruptAndSend(item); - }, - [handleInterruptAndSend, handleNativeSteerQueuedMessage, shouldUseNativeQueueSteer] - ); - const handleReorderQueueItem = useCallback( async (activeCid: string, overCid: string) => { try { @@ -5319,6 +5316,32 @@ export const SessionChatInterface = memo( [captureSessionEvent, reorderMessageQueueItem, t] ); + const handleSteerQueuedMessage = useCallback( + async (item: MessageQueueItem) => { + if (shouldUseNativeQueueSteer) { + await handleNativeSteerQueuedMessage(item); + return; + } + + // The compatibility path cancels the active turn and lets the daemon promote + // the queue head. Move an explicitly selected later item to that position first, + // preserving the user's choice without creating a second dispatch path. + await steerQueuedMessageWithFallback({ + queueItemCids: messageQueue.map((queuedItem) => queuedItem.$cid), + selectedCid: item.$cid, + reorder: handleReorderQueueItem, + interrupt: () => handleInterruptAndSend(item), + }); + }, + [ + handleInterruptAndSend, + handleNativeSteerQueuedMessage, + handleReorderQueueItem, + messageQueue, + shouldUseNativeQueueSteer, + ] + ); + const handleStartQueueItemEdit = useCallback( async (item: MessageQueueItem) => { const isFirstItem = messageQueue[0]?.$cid === item.$cid; diff --git a/packages/components/src/components/sessions/session-message-submit-route.ts b/packages/components/src/components/sessions/session-message-submit-route.ts index dee7f6a09..7ce93d2bb 100644 --- a/packages/components/src/components/sessions/session-message-submit-route.ts +++ b/packages/components/src/components/sessions/session-message-submit-route.ts @@ -6,6 +6,7 @@ export type SessionMessageSubmitRoute = export type SessionMessageSubmitRouteInput = { forceDirect: boolean; forceQueue: boolean; + invertQueuedBehavior: boolean; isPromptBusy: boolean; hasUnfinishedAssistantTurn: boolean; queuedMessageBehavior: 'queue' | 'guide'; @@ -23,6 +24,7 @@ export type SessionMessageSubmitRouteInput = { export function resolveSessionMessageSubmitRoute({ forceDirect, forceQueue, + invertQueuedBehavior, isPromptBusy, hasUnfinishedAssistantTurn, queuedMessageBehavior, @@ -30,10 +32,15 @@ export function resolveSessionMessageSubmitRoute({ if (forceDirect) { return { type: 'direct_dispatch' }; } + const effectiveQueuedMessageBehavior = invertQueuedBehavior + ? queuedMessageBehavior === 'queue' + ? 'guide' + : 'queue' + : queuedMessageBehavior; if ( !forceQueue && isPromptBusy && - queuedMessageBehavior === 'guide' && + effectiveQueuedMessageBehavior === 'guide' && hasUnfinishedAssistantTurn ) { return { type: 'guide' }; diff --git a/packages/components/src/lib/commands/built-ins.ts b/packages/components/src/lib/commands/built-ins.ts index 1e3edabfa..a0232aa2b 100644 --- a/packages/components/src/lib/commands/built-ins.ts +++ b/packages/components/src/lib/commands/built-ins.ts @@ -92,6 +92,12 @@ const UNAVAILABLE_COMMANDS: BuiltInCommandDefinition[] = [ title: 'Focus Current Input', category: 'Editor', }, + { + id: 'session.sendWithInverseQueueBehavior', + titleKey: 'commands.session.sendWithInverseQueueBehavior', + title: 'Send with Opposite Queue/Steer Behavior', + category: 'Session', + }, { id: 'session.toggleExplorerSidebar', titleKey: 'commands.session.toggleExplorerSidebar', diff --git a/packages/components/src/lib/commands/shortcuts.ts b/packages/components/src/lib/commands/shortcuts.ts index d16341b5f..0a674f39b 100644 --- a/packages/components/src/lib/commands/shortcuts.ts +++ b/packages/components/src/lib/commands/shortcuts.ts @@ -14,6 +14,7 @@ export type ShortcutCommandId = | 'session.toggleCurrentPinned' | 'session.searchCurrent' | 'session.focusInput' + | 'session.sendWithInverseQueueBehavior' | 'session.toggleExplorerSidebar' | 'session.copyCurrentBranch' | 'session.copyUrl' @@ -67,6 +68,7 @@ export const COMMAND_SHORTCUTS: Record = // Desktop ⌘L focuses the composer. On web the browser owns ⌘L (Open Location), // so leave it unbound — the hint chip follows the resolved binding. 'session.focusInput': [electron('Mod+l')], + 'session.sendWithInverseQueueBehavior': [whileComposerFocused('Mod+Shift+Enter')], 'session.toggleExplorerSidebar': ['Mod+Alt+b'], 'session.copyCurrentBranch': ['Alt+Shift+b'], 'session.copyUrl': ['Alt+Shift+c'], diff --git a/packages/components/tests/commands-built-ins.test.ts b/packages/components/tests/commands-built-ins.test.ts index 98e39a13f..06cc93d00 100644 --- a/packages/components/tests/commands-built-ins.test.ts +++ b/packages/components/tests/commands-built-ins.test.ts @@ -22,6 +22,9 @@ describe('built-in commands', () => { expect(commands.getDefaultKeybindingsFor('session.archiveCurrent')).toEqual(['Mod+Alt+a']); expect(commands.getDefaultKeybindingsFor('session.searchCurrent')).toEqual(['Mod+Alt+f']); expect(commands.getDefaultKeybindingsFor('session.focusInput')).toEqual([]); + expect(commands.getDefaultKeybindingsFor('session.sendWithInverseQueueBehavior')).toEqual([ + 'Mod+Shift+Enter', + ]); expect(commands.getDefaultKeybindingsFor('session.nextTab')).toEqual([]); expect(commands.getDefaultKeybindingsFor('session.previousVisible')).toEqual([]); // ⌥N works on web too (always a new tab there); ⌘[/⌘] back/forward and the terminal @@ -53,6 +56,9 @@ describe('built-in commands', () => { expect(commands.getDefaultKeybindingsFor('session.searchCurrent')).toEqual(['Mod+f']); expect(commands.getDefaultKeybindingsFor('session.focusInput')).toEqual(['Mod+l']); + expect(commands.getDefaultKeybindingsFor('session.sendWithInverseQueueBehavior')).toEqual([ + 'Mod+Shift+Enter', + ]); expect(commands.getDefaultKeybindingsFor('session.nextTab')).toEqual(['Mod+Shift+.']); expect(commands.getDefaultKeybindingsFor('session.previousTab')).toEqual(['Mod+Shift+,']); expect(commands.getDefaultKeybindingsFor('session.previousVisible')).toEqual(['Mod+Shift+[']); diff --git a/packages/components/tests/message-queue-row-editing.test.tsx b/packages/components/tests/message-queue-row-editing.test.tsx index 0f950e194..4861d9e0c 100644 --- a/packages/components/tests/message-queue-row-editing.test.tsx +++ b/packages/components/tests/message-queue-row-editing.test.tsx @@ -14,15 +14,15 @@ import { initI18n } from '../src/i18n'; const ORIGINAL_TASK = 'Refactor the message queue'; -function makeItem(): MessageQueueItem { +function makeItem(cid = 'cid-0', task = ORIGINAL_TASK): MessageQueueItem { return { - $cid: 'cid-0', - task: ORIGINAL_TASK, + $cid: cid, + task, userId: 'user-1', - userTurnId: 'turn-0', + userTurnId: `turn-${cid}`, timestamp: '2026-01-01T00:00:00.000Z', acpSessionConfig: { - prompt: ORIGINAL_TASK, + prompt: task, cliType: 'claude-code', agentType: 'claude-code', }, @@ -232,4 +232,44 @@ describe('queued message editing commits', () => { expect(saved).toEqual([]); expect(cancelled).toEqual(['cid-0']); }); + + it('offers steer on every queued item and makes each message body draggable', async () => { + const items = [makeItem('cid-0', 'First queued task'), makeItem('cid-1', 'Later queued task')]; + const steered: string[] = []; + await act(async () => { + root?.render( + createElement(MessageQueueDisplay, { + sessionId: 'session-test' as SessionId, + items, + onRemove: () => undefined, + onReorder: () => undefined, + onEditStart: () => undefined, + onEditCancel: () => undefined, + onEditSave: () => undefined, + onSteer: (item: MessageQueueItem) => { + steered.push(item.$cid); + }, + showSteerAction: true, + }) + ); + }); + + const steerButtons = Array.from( + container!.querySelectorAll( + '[aria-label="Steer the active response with this message"]' + ) + ); + expect(steerButtons).toHaveLength(2); + await act(async () => { + steerButtons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(steered).toEqual(['cid-1']); + + const dragTargets = Array.from( + container!.querySelectorAll('[aria-label="Drag to reorder"]') + ); + expect(dragTargets).toHaveLength(2); + expect(dragTargets[1]?.textContent).toContain('Later queued task'); + expect(dragTargets[1]?.contains(steerButtons[1] ?? null)).toBe(false); + }); }); diff --git a/packages/components/tests/queued-message-steer.test.ts b/packages/components/tests/queued-message-steer.test.ts index 18c3bcd1b..542bf1d04 100644 --- a/packages/components/tests/queued-message-steer.test.ts +++ b/packages/components/tests/queued-message-steer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { shouldRequestNativeQueueSteer } from '../src/components/sessions/message-queue/queued-message-steer'; +import { + resolveFallbackQueueSteerPreparation, + shouldRequestNativeQueueSteer, + steerQueuedMessageWithFallback, +} from '../src/components/sessions/message-queue/queued-message-steer'; describe('shouldRequestNativeQueueSteer', () => { it.each([ @@ -12,3 +16,59 @@ describe('shouldRequestNativeQueueSteer', () => { expect(shouldRequestNativeQueueSteer(authority, capability)).toBe(expected); }); }); + +describe('resolveFallbackQueueSteerPreparation', () => { + it('moves a selected later item to the queue head before interrupting', () => { + expect(resolveFallbackQueueSteerPreparation(['first', 'second', 'third'], 'third')).toEqual({ + type: 'reorder', + activeCid: 'third', + overCid: 'first', + }); + }); + + it('leaves the queue head in place and rejects stale selections', () => { + expect(resolveFallbackQueueSteerPreparation(['first', 'second'], 'first')).toEqual({ + type: 'ready', + }); + expect(resolveFallbackQueueSteerPreparation(['first', 'second'], 'missing')).toEqual({ + type: 'missing', + }); + }); +}); + +describe('steerQueuedMessageWithFallback', () => { + it('reorders a later selection before interrupting', async () => { + const events: string[] = []; + const result = await steerQueuedMessageWithFallback({ + queueItemCids: ['first', 'second'], + selectedCid: 'second', + reorder: async (activeCid, overCid) => { + events.push(`reorder:${activeCid}:${overCid}`); + }, + interrupt: async () => { + events.push('interrupt'); + }, + }); + + expect(result).toBe('steered'); + expect(events).toEqual(['reorder:second:first', 'interrupt']); + }); + + it('does not interrupt when reordering fails', async () => { + const events: string[] = []; + const result = await steerQueuedMessageWithFallback({ + queueItemCids: ['first', 'second'], + selectedCid: 'second', + reorder: async () => { + events.push('reorder'); + throw new Error('reorder failed'); + }, + interrupt: async () => { + events.push('interrupt'); + }, + }); + + expect(result).toBe('reorder_failed'); + expect(events).toEqual(['reorder']); + }); +}); diff --git a/packages/components/tests/session-chat-input-submission.test.tsx b/packages/components/tests/session-chat-input-submission.test.tsx index cd38a637c..ba4a717c1 100644 --- a/packages/components/tests/session-chat-input-submission.test.tsx +++ b/packages/components/tests/session-chat-input-submission.test.tsx @@ -63,8 +63,10 @@ import { SessionChatInputArea, setSessionChatInputTextDraft, type SessionChatInputAreaHandle, + type SessionChatInputAreaProps, } from '../src/components/sessions/session-chat-input-area'; import { initI18n } from '../src/i18n'; +import { commands } from '../src/lib/commands'; ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -342,7 +344,7 @@ describe('SessionChatInputArea submission feedback', () => { claimNavigationFocus, }: { sessionId?: string; - onSendMessage: (blocks: SessionInputBlock[]) => Promise; + onSendMessage: SessionChatInputAreaProps['onSendMessage']; isArchived?: boolean; composerRef?: RefObject; claimNavigationFocus?: () => boolean; @@ -402,6 +404,19 @@ describe('SessionChatInputArea submission feedback', () => { }); } + it('submits the current draft with one-shot inverse queue behavior', async () => { + const onSendMessage = vi.fn(async () => true); + await renderComposer({ sessionId: 'inverse-queue-submit', onSendMessage }); + + await act(async () => { + expect(commands.execute('session.sendWithInverseQueueBehavior')).toBe(true); + await Promise.resolve(); + }); + + expect(onSendMessage).toHaveBeenCalledOnce(); + expect(onSendMessage.mock.calls[0]?.[2]).toEqual({ invertQueuedBehavior: true }); + }); + it.each([ ['keyboard', true], ['keyboard', false], diff --git a/packages/components/tests/session-message-submit-route.test.ts b/packages/components/tests/session-message-submit-route.test.ts index 444d6df9e..fccaba37f 100644 --- a/packages/components/tests/session-message-submit-route.test.ts +++ b/packages/components/tests/session-message-submit-route.test.ts @@ -5,6 +5,7 @@ const resolve = (overrides: Partial { ).toEqual({ type: 'guide' }); }); + it('inverts the configured queue behavior for an explicit submission', () => { + expect( + resolve({ + isPromptBusy: true, + hasUnfinishedAssistantTurn: true, + queuedMessageBehavior: 'queue', + invertQueuedBehavior: true, + }) + ).toEqual({ type: 'guide' }); + expect( + resolve({ + isPromptBusy: true, + hasUnfinishedAssistantTurn: true, + queuedMessageBehavior: 'guide', + invertQueuedBehavior: true, + }) + ).toEqual({ type: 'queue', reason: 'prompt_busy' }); + }); + it('honors explicit route overrides with forceDirect taking precedence', () => { expect(resolve({ forceQueue: true })).toEqual({ type: 'queue', reason: 'forced' }); expect( diff --git a/specs/message-queue-interactions.md b/specs/message-queue-interactions.md new file mode 100644 index 000000000..35f1952d3 --- /dev/null +++ b/specs/message-queue-interactions.md @@ -0,0 +1,46 @@ +# Message queue interactions + +Status: draft +Translation: current + +[中文](message-queue-interactions.zh.md) + +## Scenario + +A user writes while an agent is working and chooses Queue or Steer as the default +behavior. They may need the opposite behavior for one message, or may decide that +any already queued message should steer the active response without first rearranging +the queue by hand. + +## Contract + +- `Mod+Shift+Enter` sends the current composer draft with the opposite of the stored + Queue/Steer preference. This is a one-shot submission intent: it does not mutate the + preference, and it still obeys the ordinary availability, live-activity, and unfinished- + transcript safeguards. +- While steering is available for the active turn, every queued message exposes Steer. + Selecting a later item targets that exact item. Native acknowledged steering removes + and applies the selected item directly; the compatibility path first moves it to the + queue head and interrupts only after the reorder succeeds. +- The number and non-editing message body form the drag target for queue reordering. + Steer, edit, and remove remain separate controls and must not begin a drag. +- Editing keeps its existing keyboard and focus behavior and disables reordering for + that row until editing ends. + +## Limits and review questions + +The shortcut changes routing only when Queue and Steer are meaningfully distinct. An +idle session still dispatches normally, and a session without positive live prompt +activity retains the conservative queue barrier even if the inverse intent would be +Steer. Touch and pointer interactions share the same drag target; installed-app and +physical-device coverage remains separate from component tests. + +## Implementation evidence + +- `packages/components/src/components/sessions/session-message-submit-route.ts` +- `packages/components/src/components/sessions/session-chat-input-area.tsx` +- `packages/components/src/components/sessions/message-queue/` +- `packages/components/tests/{session-message-submit-route,queued-message-steer,message-queue-row-editing}.test.*` +- [Decision record](../.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md) + +This is a draft for human review. Implementation and passing tests do not approve it. diff --git a/specs/message-queue-interactions.zh.md b/specs/message-queue-interactions.zh.md new file mode 100644 index 000000000..d9277d56b --- /dev/null +++ b/specs/message-queue-interactions.zh.md @@ -0,0 +1,37 @@ +# 消息队列交互 + +Status: draft +Translation: current + +[English](message-queue-interactions.md) + +## 场景 + +用户在 Agent 工作时继续输入,并将“排队”或“引导”设为默认行为。他们可能只想让某一条 +消息采用相反行为,也可能希望直接用任意一条已排队消息引导当前回复,而不必先手动调整队列。 + +## 契约 + +- `Mod+Shift+Enter` 以已保存“排队/引导”偏好的相反行为发送当前草稿。这是一次性的提交 + 意图,不修改设置,并继续遵守普通的可用性、实时活动和未完成消息记录保护。 +- 当前 turn 可接受引导时,每条排队消息都提供“引导”操作。选择后续项必须以该项为目标。 + 原生带确认的 steer 直接移除并应用所选项;兼容路径先把它移到队首,只有重排成功后才中断。 +- 序号和非编辑状态的消息正文共同组成队列重排拖动区域。“引导”、“编辑”和“移除”是独立 + 控件,不能触发拖动。 +- 编辑状态保留已有键盘与焦点行为,并在编辑结束前禁用该行重排。 + +## 边界与待审事项 + +只有“排队”和“引导”具有不同含义时,该快捷键才改变路由。空闲会话仍正常直接发送;没有 +明确实时 prompt 活动时,即使反转意图是“引导”,也继续采用保守的排队屏障。触摸和鼠标共用 +同一拖动区域;已安装应用和物理设备验证不由组件测试替代。 + +## 实现证据 + +- `packages/components/src/components/sessions/session-message-submit-route.ts` +- `packages/components/src/components/sessions/session-chat-input-area.tsx` +- `packages/components/src/components/sessions/message-queue/` +- `packages/components/tests/{session-message-submit-route,queued-message-steer,message-queue-row-editing}.test.*` +- [决策记录](../.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md) + +这是供人工审阅的草稿;实现和测试通过不代表 Spec 已获批准。 From 31de8acad286fc20318b2553d7ca4d716ee90373 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:47:22 +0800 Subject: [PATCH 02/15] fix: steer queued messages by identity Model: gpt-5 --- .agents/docs/rpc-loro-streams-rpc.md | 5 + .agents/docs/sessions-live-status.md | 8 +- .../2026-09-13-queue-steer-controls.md | 42 ++-- .../2026-09-13-queue-steer-controls.zh.md | 28 ++- apps/cli/src/lib/loro/doc-user-turn.test.ts | 48 ++++- apps/cli/src/lib/loro/doc.ts | 57 +++++ apps/cli/src/lib/message-handler.ts | 24 +++ apps/cli/src/session/AGENTS.md | 2 +- apps/cli/src/session/README.md | 6 +- apps/cli/src/session/queued-message-turn.ts | 63 ++++++ .../src/session/session-dispatch-watcher.ts | 61 +----- .../src/session/session-execution-service.ts | 88 ++++++++ .../tests/session-execution-service.test.ts | 140 ++++++++++++ locales/en.json | 1 + locales/zh_CN.json | 1 + packages/components/src/atoms/runtime.ts | 11 + .../sessions/message-queue/AGENTS.md | 14 +- .../sessions/message-queue/index.ts | 5 - .../message-queue/queued-message-steer.ts | 51 ----- .../sessions/session-chat-input-area.tsx | 22 +- .../sessions/session-chat-interface.tsx | 199 +++++------------- .../sessions/session-message-submit-route.ts | 6 +- .../src/hooks/use-session-actions.ts | 41 ++++ .../components/src/lib/commands/shortcuts.ts | 2 +- .../src/providers/create-workspace-runtime.ts | 2 + .../providers/workspace-machine-rpc-facade.ts | 51 +++++ .../tests/queued-message-steer.test.ts | 74 ------- .../session-chat-input-submission.test.tsx | 9 +- .../session-message-submit-route.test.ts | 6 +- .../tests/use-session-actions.test.ts | 31 +++ .../workspace-machine-rpc-facade.test.ts | 52 +++++ packages/loro-streams-rpc/AGENTS.md | 2 +- .../src/machine-rpc-server.ts | 26 +++ packages/loro-streams-rpc/src/rpc.ts | 73 +++++++ .../tests/loro-streams-rpc.test.ts | 24 +++ packages/shared/src/local-machine-rpc.ts | 13 ++ packages/shared/src/message-schemas.ts | 22 ++ packages/shared/src/message.ts | 17 ++ .../shared/tests/local-machine-rpc.test.ts | 9 + packages/shared/tests/message-schemas.test.ts | 16 ++ specs/message-queue-interactions.md | 22 +- specs/message-queue-interactions.zh.md | 13 +- 42 files changed, 974 insertions(+), 413 deletions(-) create mode 100644 apps/cli/src/session/queued-message-turn.ts delete mode 100644 packages/components/src/components/sessions/message-queue/queued-message-steer.ts delete mode 100644 packages/components/tests/queued-message-steer.test.ts diff --git a/.agents/docs/rpc-loro-streams-rpc.md b/.agents/docs/rpc-loro-streams-rpc.md index 54bafb53e..cda7c3eed 100644 --- a/.agents/docs/rpc-loro-streams-rpc.md +++ b/.agents/docs/rpc-loro-streams-rpc.md @@ -20,6 +20,11 @@ been removed. handlers. - `README.md` — package smoke-test notes. +`session/queue-steer` is an identity-based control operation, not queue reordering. +Its request names both the expected active turn and the exact queued item. The CLI +consumes that item before cancelling the expected turn; a missing item or stale turn +is rejected without stopping the current agent. + ## Remote lifecycle acknowledgements For accepted restart/upgrade responses, `settleMachineLifecycleResponse` attempts diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index d8bc60f78..bd7389c5f 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -43,9 +43,11 @@ this page is the full text of the rules summarised there. must queue in that state (even when the preference is guide; steering requires positive live prompt activity), because queue promotion is safe for both a live turn and a stale transcript while direct dispatch can create a second accepted - turn. An explicit inverse submission swaps queue and guide for that submission - only, then applies the same activity and ordering gates; it never changes the - stored preference. This barrier affects routing only; it must not relight Working + turn. An explicit `queueBehavior: "inverse"` submission swaps queue and guide for + that submission only, then applies the same activity and ordering gates; ordinary + Enter supplies no override and neither path changes the stored preference. The + inverse command owns its composer-focus/content/readiness predicate so rebinding its + shortcut cannot bypass those constraints. This barrier affects routing only; it must not relight Working UI or enable Stop. That pre-start label is additionally suppressed whenever the status chip has an active connection/machine problem (`statusStripState != null`: browser offline, machine removed or offline) — the chip owns that story, diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md index 260e331fc..7da6fc93a 100644 --- a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md @@ -10,38 +10,44 @@ Translation: current Queue and Steer previously required changing a persistent preference, later queued items hid Steer, and reordering started only from the small leading handle. The adopted interaction adds a one-shot inverse submission command, exposes Steer on every row, and -uses the row's message content as its drag target. Providers without acknowledged native -steering preserve their compatibility path by moving a selected later item to the head -before cancellation; a failed reorder leaves the active turn untouched. +uses the row's message content as its drag target. Queue order and immediate Steer remain +independent: the daemon consumes a selected queue identity directly, and a missing identity +leaves the active turn untouched. ## Decision - Register `session.sendWithInverseQueueBehavior` in the command system with - `Mod+Shift+Enter` as its composer-focused default. The composer sends an explicit - per-submission option; the routing resolver reverses the effective preference without - writing settings or bypassing activity guards. -- Show Steer for every row whenever the session-level action is available. Native steer - continues to materialize and remove the selected item by identity. The compatibility - path retains one queue consumer: it reorders the selection to the head, then uses the - existing cancel-and-promote flow. + `Mod+Shift+Enter` as its default. The composer calls + `sendMessage({ queueBehavior: "inverse" })`; ordinary submission passes no option. The + command-level predicate owns composer focus, content, and send readiness so user binding + overrides cannot remove those rules. The routing resolver reverses only this submission. +- Show Steer for every row whenever the session-level action is available. The renderer sends + the queue `$cid` and expected active turn through `session/queue-steer`, then waits for the + daemon result without changing queue or history locally. Under its per-session mutation and + history-rewrite leases, the daemon revalidates the turn and consumes that exact row into + history in one Session Doc mutation before requesting exact-turn cancellation. - Make the leading number and message body a single pointer and keyboard drag activator. Keep action buttons outside it, and disable it while the row editor owns interaction. ## Alternatives and trade-offs -Keeping Steer on the first row would preserve the old fallback assumption but require -users to perform a separate reorder. Sending a selected compatibility item through a new -direct-dispatch path was rejected because it would duplicate the daemon's queue promotion -ownership and create a second ordering boundary. Making the complete row draggable was -also rejected because Steer, edit, and remove would become accidental drag starters. +Keeping Steer on the first row would require users to perform an unrelated reorder first. +Reorder-then-cancel was rejected because reorder can resolve after a concurrent peer deleted +the selected row, causing Stop to target the current turn without any message to promote. +Renderer-side history materialization was rejected because the renderer cannot atomically +validate daemon turn ownership and queue identity. Making the complete row draggable was also +rejected because Steer, edit, and remove would become accidental drag starters. ## Verification and limits - Routing tests cover Queue → Steer and Steer → Queue inversion while a prompt is live. -- Command registry tests cover the default binding in web and Electron environments. +- Command tests cover the default binding, explicit submission option, and command-level + composer-focus rule. - Queue component tests cover Steer on a later row and verify that message content, but - not action buttons, belongs to the drag activator. Queue-steer tests cover later-item - preparation, an already-first item, and a stale selection. + not action buttons, belongs to the drag activator. +- CLI service and Session Doc tests cover consuming C from `[A, B, C]` as `[A, B]`, exact-turn + cancellation after consumption, and the missing-C failure path that never calls Stop. +- Machine RPC schema tests cover both required identities. - Component tests use synthetic pointer state. Physical touch dragging and a full provider-backed steer run were not exercised. diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md index 91f468068..554636235 100644 --- a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md @@ -9,31 +9,37 @@ Translation: current 此前若要在“排队”和“引导”间切换,必须修改持久设置;后续队列项不显示“引导”,重排也只能 从狭小的左侧把手开始。本次采用一次性反转提交命令、让每行都显示“引导”,并把消息内容区 -作为拖动区域。对于没有原生带确认 steer 的 Provider,兼容路径会在取消前先把所选后续项移到 -队首;如果重排失败,则不触碰当前 turn。 +作为拖动区域。队列顺序与立即“引导”保持独立:daemon 直接消费所选队列 ID;如果该 ID 已 +不存在,则当前 turn 不受影响。 ## 决策 - 在命令系统注册 `session.sendWithInverseQueueBehavior`,默认仅在输入框聚焦时由 - `Mod+Shift+Enter` 触发。输入框传递显式的单次提交选项;路由器只反转本次有效偏好, - 不写入设置,也不绕过活动状态保护。 -- 会话允许引导时,每一行都显示“引导”。原生 steer 继续按身份生成历史并移除所选项; - 兼容路径仍只有一个队列消费者:先把所选项移到队首,再沿用取消与队列提升流程。 + `Mod+Shift+Enter` 触发。输入框直接调用 `sendMessage({ queueBehavior: "inverse" })`,普通 + 提交不传选项。输入框聚焦、有内容且可以发送是命令级条件,因此用户覆盖 binding 后也不会 + 丢失;路由器只反转本次有效偏好。 +- 会话允许引导时,每一行都显示“引导”。Renderer 通过 `session/queue-steer` 发送队列 `$cid` + 和预期活动 turn,等待 daemon 返回,且不在本地修改队列或历史。Daemon 在每会话 mutation + 与历史 rewrite lease 下重新确认 turn,并在一次 Session Doc 更新中把该行消费进历史,再请求 + 精确取消该 turn。 - 左侧序号和消息正文合并为一个支持鼠标及键盘的拖动区域。操作按钮保持在区域外;编辑器 接管交互时禁用该行拖动。 ## 备选方案与取舍 -只允许队首“引导”可维持旧兼容路径假设,但用户仍必须额外重排。没有采用新的直接发送路径 -来发送兼容模式下的所选项,因为这会复制 daemon 的队列提升职责并增加第二个顺序边界。 -也没有让整行都可拖动,因为那会使“引导”、“编辑”和“移除”成为意外的拖动起点。 +只允许队首“引导”会迫使用户先做一次无关重排。没有采用“重排后取消”:并发客户端删除所选 +行时,重排仍可能 resolve,进而在没有可提升消息的情况下错误 Stop 当前 turn。也没有让 +Renderer 先写历史,因为它无法原子确认 daemon 的 turn 所有权和队列 ID。没有让整行都可拖动, +因为那会使“引导”、“编辑”和“移除”成为意外的拖动起点。 ## 验证与边界 - 路由测试覆盖 prompt 活动时“排队 → 引导”和“引导 → 排队”的反转。 -- 命令注册测试覆盖 Web 与 Electron 环境中的默认快捷键。 +- 命令测试覆盖默认 binding、显式提交选项和命令级输入框聚焦规则。 - 队列组件测试覆盖对后续项执行“引导”,并验证消息内容属于拖动区域而操作按钮不属于。 - 队列 steer 测试覆盖后续项准备、已在队首的项以及过期选择。 +- CLI service 与 Session Doc 测试覆盖从 `[A, B, C]` 消费 C 后保留 `[A, B]`、消费后精确取消 + turn,以及 C 已缺失时绝不调用 Stop。 +- Machine RPC schema 测试覆盖两个必需 ID。 - 组件测试使用合成指针状态;未验证物理触摸拖动和完整的 Provider-backed steer 流程。 ## 参考 diff --git a/apps/cli/src/lib/loro/doc-user-turn.test.ts b/apps/cli/src/lib/loro/doc-user-turn.test.ts index d299313bf..805e3301e 100644 --- a/apps/cli/src/lib/loro/doc-user-turn.test.ts +++ b/apps/cli/src/lib/loro/doc-user-turn.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { createSessionMirror, type SessionHistoryInput, type SessionId } from '@lody/shared'; +import { + createSessionMirror, + type MessageQueueItem, + type SessionHistoryInput, + type SessionId, +} from '@lody/shared'; import { LoroDoc, LoroList, LoroMap } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; @@ -19,7 +24,10 @@ const createLogger = (): Logger => * runs; `history` mirrors what the CRDT would hold. */ const createSessionDocument = (repo: Partial) => { - const state: { history: SessionHistoryInput[] } = { history: [] }; + const state: { history: SessionHistoryInput[]; mq: MessageQueueItem[] } = { + history: [], + mq: [], + }; const doc = new SessionDocument( repo as LoroRepo, 'session-append-1' as SessionId, @@ -44,6 +52,42 @@ const createUserTurn = (id: string): SessionHistoryInput => ({ userId: 'user-1', }); +describe('SessionDocument.consumeMessageQueueItemAsUserTurn', () => { + it('atomically consumes the named later row without reordering the survivors', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc, state } = createSessionDocument({ upsertDocMeta }); + state.mq = ['A', 'B', 'C'].map( + (cid): MessageQueueItem => ({ + $cid: cid, + task: `task ${cid}`, + timestamp: '2026-09-13T00:00:00.000Z', + }) + ); + + const result = await doc.consumeMessageQueueItemAsUserTurn('C', () => + createUserTurn('user:C') + ); + + expect(result).toMatchObject({ type: 'consumed', entry: { id: 'user:C' } }); + expect(state.history.map((entry) => entry.id)).toEqual(['user:C']); + expect(state.mq.map((item) => item.$cid)).toEqual(['A', 'B']); + expect(upsertDocMeta).toHaveBeenCalledWith(doc.roomId, { latestUserMsgId: 'user:C' }); + }); + + it('does not write history or a dispatch pointer when the identity is absent', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc, state } = createSessionDocument({ upsertDocMeta }); + state.mq = [{ $cid: 'A', task: 'task A', timestamp: '2026-09-13T00:00:00.000Z' }]; + + await expect( + doc.consumeMessageQueueItemAsUserTurn('C', () => createUserTurn('user:C')) + ).resolves.toEqual({ type: 'missing' }); + expect(state.history).toEqual([]); + expect(state.mq.map((item) => item.$cid)).toEqual(['A']); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); +}); + describe('SessionDocument.appendUserTurn', () => { it('opens old malformed notices without sanitizing stored history', () => { const { doc } = createSessionDocument({}); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 68168f5d0..e3aae3632 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -2911,6 +2911,63 @@ export class SessionDocument implements LoroDocument SessionHistoryInput | null + ): Promise< + | { type: 'consumed'; entry: SessionHistoryInput } + | { type: 'missing' } + | { type: 'invalid' } + > { + if (!this.mirror) { + throw new Error('SessionDocument not initialized'); + } + + const outcome: { + value: + | { type: 'consumed'; entry: SessionHistoryInput } + | { type: 'missing' } + | { type: 'invalid' }; + } = { value: { type: 'missing' } }; + this.mirror.setState((prev) => { + const queue = (prev.mq ?? []) as MessageQueueItem[]; + const item = queue.find((candidate) => candidate.$cid === cid); + if (!item) return prev; + + const entry = buildEntry(item); + if (!entry) { + outcome.value = { type: 'invalid' }; + return prev; + } + if (entry.role !== 'user') { + throw new Error( + `consumeMessageQueueItemAsUserTurn requires a user entry, received role "${entry.role}" for ${entry.id}` + ); + } + const history = (prev.history as SessionHistoryInput[]) || []; + // @ts-ignore - mirror state is writable inside setState. + prev.history = [...history, entry]; + // @ts-ignore - mirror state is writable inside setState. + prev.mq = queue.filter((candidate) => candidate.$cid !== cid); + outcome.value = { type: 'consumed', entry }; + return prev; + }); + + if (outcome.value.type === 'consumed') { + await this.repo.upsertDocMeta(this.roomId, { + latestUserMsgId: outcome.value.entry.id, + } satisfies Partial); + } + return outcome.value; + } + /** * Replace the fields of the message-queue item identified by `$cid`. The * caller supplies the fully-resolved next field set (the renderer resolves its diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 21de7835a..7d61314fb 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -2682,6 +2682,23 @@ export class MessageHandler { return await this.executionService.steerSession(args); } + private async steerQueuedMessageWithAccessCheck( + args: Parameters[0] + ): ReturnType { + const access = await this.verifySessionMachineAccess(args.sessionId, args.requestedByUserId); + if (access.outcome !== 'allowed') { + return { + type: 'session/queue-steer_response', + sessionId: args.sessionId, + queueItemId: args.queueItemId, + accepted: false, + disposition: 'error', + error: `Queue steer access verification ${access.outcome}`, + }; + } + return await this.executionService.steerQueuedMessage(args); + } + private async controlSessionGoalWithAccessCheck(args: { sessionId: SessionId; action: SessionGoalAction; @@ -3327,6 +3344,7 @@ export class MessageHandler { }; }, steerSession: async (args) => await this.steerSessionWithAccessCheck(args), + steerQueuedMessage: async (args) => await this.steerQueuedMessageWithAccessCheck(args), controlSessionGoal: async (args) => await this.controlSessionGoalWithAccessCheck(args), terminateSession: async ({ sessionId }) => await this.terminateAcpSession(sessionId), forkSession: async (args) => await this.forkSessionWithAccessCheck(args), @@ -6357,6 +6375,12 @@ export class MessageHandler { sessionId: request.params.sessionId as SessionId, }); } + case 'session/queue-steer': { + return await this.steerQueuedMessageWithAccessCheck({ + ...request.params, + sessionId: request.params.sessionId as SessionId, + }); + } case 'session/goal': { return await this.controlSessionGoalWithAccessCheck({ ...request.params, diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index d06019006..527054444 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -25,7 +25,7 @@ Contract: specs/session-orchestration.md. ## Dispatch -- Queue promotion preserves frozen fields; remove its row only after history and activation succeed. +- Queue promotion preserves frozen fields; Queue Steer consumes exact `$cid`; missing never stops. - Absent session meta is "unknown", not foreign: hold the TTL-bounded RPC stash until meta lands; drop it only on a definitive verdict. - Subscribe to RPC offers BEFORE awaiting Doc Room join/sync and never dispatch from the RPC diff --git a/apps/cli/src/session/README.md b/apps/cli/src/session/README.md index fc0add084..e7318f1d0 100644 --- a/apps/cli/src/session/README.md +++ b/apps/cli/src/session/README.md @@ -19,10 +19,14 @@ CLI/MCP orchestration contract is specs/session-orchestration.md. authorized or executed. Its extensive header comment is the authoritative doc for edge cases (stale pointers, history/meta sync races). - `session-dispatch-logic.ts` — pure decision functions for the watcher (testable). +- `queued-message-turn.ts` — the shared queue-item-to-User-turn conversion used by + both ordinary queue dispatch and exact-item Steer consumption. - `turn-history-gate.ts` — ordering barrier for RPC fast-path turns. Created in message-handler's `beginConversationTurn`, stored/disposed via `SessionTransientStore` turn state; it creates the assistant entry when it opens. -- `session-execution-service.ts` — runs one turn end-to-end: ACP prompt, turn ids, +- `session-execution-service.ts` — runs one turn end-to-end and owns exact-item queue + Steer serialization: validate the active turn and queued item, consume that item, + then cancel only the expected turn. It also owns ACP prompt, turn ids, lifecycle/error handling, GitHub/local project setup, and post-turn diffStats. - `acp-error-classification.ts` — JSON-RPC/transport error string matching for the above. - `session-manager.ts` / `session.ts` / `session-sandbox.ts` / `terminal-manager.ts` — diff --git a/apps/cli/src/session/queued-message-turn.ts b/apps/cli/src/session/queued-message-turn.ts new file mode 100644 index 000000000..37472c0d6 --- /dev/null +++ b/apps/cli/src/session/queued-message-turn.ts @@ -0,0 +1,63 @@ +import { + buildPendingUserHistoryEntry, + buildSessionTurnInputConfig, + normalizeMcpServerIdSelection, + type AcpConfigOptionValue, + type MessageQueueItem, + type SessionHistoryInput, + type SessionMeta, +} from '@lody/shared'; +import { + extractPromptPreviewFromInputBlocks, + normalizeSessionInputBlocks, +} from './session-execution-helpers'; +import { resolveResumableAcpSessionId } from './session-dispatch-logic'; + +const isConfigOptionValueRecord = ( + value: unknown +): value is Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return Object.values(value as Record).every( + (item) => typeof item === 'string' || typeof item === 'boolean' + ); +}; + +/** Materialize one durable queue item as the user turn that will execute it. */ +export function buildQueuedMessageUserTurn( + queuedItem: MessageQueueItem, + meta: SessionMeta +): SessionHistoryInput | null { + const inputBlocks = normalizeSessionInputBlocks( + queuedItem.acpSessionConfig?.inputBlocks, + queuedItem.acpSessionConfig?.prompt ?? queuedItem.task + ); + const inputConfig = buildSessionTurnInputConfig({ + inputBlocks, + prompt: queuedItem.acpSessionConfig?.prompt ?? extractPromptPreviewFromInputBlocks(inputBlocks), + cliType: queuedItem.acpSessionConfig?.cliType ?? meta.cliType, + agentType: queuedItem.acpSessionConfig?.agentType ?? meta.agentType, + modeId: queuedItem.acpSessionConfig?.modeId, + modelId: queuedItem.acpSessionConfig?.modelId, + configOptionValues: isConfigOptionValueRecord(queuedItem.acpSessionConfig?.configOptionValues) + ? queuedItem.acpSessionConfig.configOptionValues + : undefined, + mcpServerIds: normalizeMcpServerIdSelection(queuedItem.acpSessionConfig?.mcpServerIds) ?? [], + taskToolsEnabled: queuedItem.acpSessionConfig?.taskToolsEnabled === true, + agentRoleId: queuedItem.acpSessionConfig?.agentRoleId, + agentRoleRevision: queuedItem.acpSessionConfig?.agentRoleRevision, + issuePRMentions: queuedItem.acpSessionConfig?.issuePRMentions, + resume: resolveResumableAcpSessionId(meta), + }); + const pendingEntry = buildPendingUserHistoryEntry({ + userId: queuedItem.userId ?? meta.userId, + inputBlocks, + timestamp: queuedItem.timestamp, + inputConfig, + }); + if (!pendingEntry) return null; + + return { + ...pendingEntry, + id: queuedItem.userTurnId?.trim() || `queued-${queuedItem.$cid}`, + }; +} diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index e3a4683a2..299005ca5 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -3,13 +3,11 @@ import { Effect, Fiber } from 'effect'; import { buildMissingEmail, buildPendingUserHistoryEntry, - buildSessionTurnInputConfig, getSessionRoomId, getLegacyReadForSessionHistoryStatus, type ChatFailedReason, isLoroRepoDocDeleted, isSessionDocRoomId, - type AcpConfigOptionValue, type MessageQueueItem, type MachineId, SESSION_DOC_PREFIX, @@ -23,7 +21,6 @@ import { SessionStatusFactory, type SessionTurnInputConfig, type WorkspaceId, - normalizeMcpServerIdSelection, getPendingUserTurnActivationId, hasPendingUserTurnActivation, } from '@lody/shared'; @@ -32,17 +29,13 @@ import { formatErrorMessage } from '@/utils/format-error'; import { startTraceSpan, traceAsync } from '@/utils/trace-span'; import type { LoroDocumentManager } from '@/lib/loro/doc'; import { SessionExecutionService, type SessionDispatchSource } from './session-execution-service'; -import { - extractPromptPreviewFromInputBlocks, - normalizeSessionInputBlocks, -} from './session-execution-helpers'; +import { normalizeSessionInputBlocks } from './session-execution-helpers'; import type { SessionUserResolver, SessionUserProfile } from './session-user-resolver'; import { findNextDispatchableUserTurn, isActivationAwaitingHistory, resolveDispatchTurnInput, resolveDispatchAcpSessionId, - resolveResumableAcpSessionId, resolveSessionCancelAction, resolveSessionDispatchAction, shouldWatchSession, @@ -57,6 +50,7 @@ import { resolveSessionLaunchConfig } from './session-launch-config-resolver'; import type { SessionAccessPolicyService } from './session-access-policy'; import { mapWithConcurrency } from '@/lib/bounded-concurrency'; import { listAliveRoomIds } from '@/lib/loro/repo-existence'; +import { buildQueuedMessageUserTurn } from './queued-message-turn'; const SESSION_RECONCILE_CONCURRENCY = 4; @@ -197,17 +191,6 @@ type SessionReconcilePhase = type SessionDocumentHandle = Awaited>; -const isConfigOptionValueRecord = ( - value: unknown -): value is Record => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - return Object.values(value as Record).every( - (item) => typeof item === 'string' || typeof item === 'boolean' - ); -}; - /** * ## Session Dispatch Watcher — Behavioral Design * @@ -2126,48 +2109,12 @@ export class SessionDispatchWatcher { return null; } - const inputBlocks = normalizeSessionInputBlocks( - queuedItem.acpSessionConfig?.inputBlocks, - queuedItem.acpSessionConfig?.prompt ?? queuedItem.task - ); - const inputConfig = buildSessionTurnInputConfig({ - inputBlocks, - prompt: - queuedItem.acpSessionConfig?.prompt ?? extractPromptPreviewFromInputBlocks(inputBlocks), - cliType: queuedItem.acpSessionConfig?.cliType ?? meta.cliType, - agentType: queuedItem.acpSessionConfig?.agentType ?? meta.agentType, - modeId: queuedItem.acpSessionConfig?.modeId, - modelId: queuedItem.acpSessionConfig?.modelId, - configOptionValues: isConfigOptionValueRecord( - queuedItem.acpSessionConfig?.configOptionValues - ) - ? queuedItem.acpSessionConfig.configOptionValues - : undefined, - mcpServerIds: - normalizeMcpServerIdSelection(queuedItem.acpSessionConfig?.mcpServerIds) ?? [], - taskToolsEnabled: queuedItem.acpSessionConfig?.taskToolsEnabled === true, - agentRoleId: queuedItem.acpSessionConfig?.agentRoleId, - agentRoleRevision: queuedItem.acpSessionConfig?.agentRoleRevision, - issuePRMentions: queuedItem.acpSessionConfig?.issuePRMentions, - resume: resolveResumableAcpSessionId(meta), - }); - const pendingEntry = buildPendingUserHistoryEntry({ - userId: queuedItem.userId ?? meta.userId, - inputBlocks, - timestamp: queuedItem.timestamp, - inputConfig, - }); - - if (!pendingEntry) { + const entry = buildQueuedMessageUserTurn(queuedItem, meta); + if (!entry) { this.deps.logger.debug(`[${meta.id}] Retaining invalid queued message ${queuedItem.$cid}`); return null; } - const entry: SessionHistoryInput = { - ...pendingEntry, - id: queuedTurnId, - }; - // Promotion is a dispatch producer; `appendUserTurn` publishes the pointer. await sessionDoc.appendUserTurn(entry); await sessionDoc.removeMessageQueueItem(queuedItem.$cid); diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 35ba71652..7316d7659 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -44,6 +44,7 @@ import { SessionChatRequestValidated, SessionCancelRequestValidated, type SessionSteerResponse, + type SessionQueueSteerResponse, type WorkspaceId, hasRecentResumeNotice, buildReplayPromptFromHistory, @@ -111,6 +112,7 @@ import { resolveResumableAcpSessionId, } from './session-dispatch-logic'; import { resolveSessionLaunchConfig } from './session-launch-config-resolver'; +import { buildQueuedMessageUserTurn } from './queued-message-turn'; import type { MachineAccessVerification } from './session-access-retry'; import { GIT_EXECUTABLE_NOT_FOUND_CODE, @@ -1387,6 +1389,92 @@ export class SessionExecutionService { }); } + async steerQueuedMessage(options: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + requestedByUserId: string; + }): Promise { + const respond = ( + disposition: SessionQueueSteerResponse['disposition'], + details?: { userTurnId?: string; error?: string } + ): SessionQueueSteerResponse => ({ + type: 'session/queue-steer_response', + sessionId: options.sessionId, + queueItemId: options.queueItemId, + accepted: disposition === 'accepted', + disposition, + ...(details?.userTurnId ? { userTurnId: details.userTurnId } : {}), + ...(details?.error ? { error: details.error } : {}), + }); + + return await this.steerMutationQueue.enqueue(options.sessionId, async () => { + const releaseConflict = this.tryAcquireSessionRewriteConflictLease(options.sessionId); + if (!releaseConflict) { + return respond('busy', { error: 'The session history is being replaced.' }); + } + try { + const runtime = this.turnRuntimeBySession.get(options.sessionId); + if (!runtime || !runtime.session || !runtime.promptInFlight || runtime.cancelRequested) { + return respond('no-active-turn'); + } + if (runtime.turnId !== options.expectedTurnId) { + return respond('stale-turn'); + } + + const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc( + options.sessionId + ); + const meta = await sessionDoc.getMetaState(); + if (!meta) { + return respond('error', { error: 'Session metadata is unavailable.' }); + } + + const currentRuntime = this.turnRuntimeBySession.get(options.sessionId); + if ( + currentRuntime !== runtime || + runtime.turnId !== options.expectedTurnId || + !runtime.promptInFlight || + runtime.cancelRequested + ) { + return respond('stale-turn'); + } + + // Queue order and immediate steering are separate mutations. Consume the + // selected identity directly; A/B keep their relative order when C wins. + const consumed = await sessionDoc.consumeMessageQueueItemAsUserTurn( + options.queueItemId, + (item) => buildQueuedMessageUserTurn(item, meta) + ); + if (consumed.type === 'missing') { + return respond('queue-item-missing'); + } + if (consumed.type === 'invalid') { + return respond('invalid-queue-item'); + } + const entry = consumed.entry; + const cancellation = await this.cancelSession({ + type: 'session/cancel', + sessionId: options.sessionId, + machineId: this.deps.machineId, + workspaceId: this.deps.workspaceId, + turnId: options.expectedTurnId, + }); + if (!cancellation.success) { + return respond('error', { + userTurnId: entry.id, + error: cancellation.error ?? 'The active turn could not be stopped.', + }); + } + return respond('accepted', { userTurnId: entry.id }); + } catch (error) { + return respond('error', { error: formatErrorMessage(error) }); + } finally { + releaseConflict(); + } + }); + } + private async steerSessionLocked(options: { sessionId: SessionId; expectedTurnId: string; diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 6db828962..ffde55895 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -25,6 +25,7 @@ import { type ChatFailedReason, type LocalProjectId, type MachineId, + type MessageQueueItem, type SessionGoalMessage, type SessionHistoryInput, type SessionId, @@ -274,6 +275,145 @@ describe('SessionExecutionService', () => { expect([...runningChildren]).toEqual(['child-2']); expect(deps.getActiveTurnId(request.sessionId)).toBe('parent-1'); }); + + it('steers an exact later queue item without reordering the remaining queue', async () => { + const sessionId = 'session-queue-steer' as SessionId; + const activeTurnId = 'assistant:active'; + const queue = ['A', 'B', 'C'].map( + (id): MessageQueueItem => ({ + $cid: id, + task: `task ${id}`, + userId: 'owner-user', + userTurnId: `user:${id}`, + timestamp: '2026-09-13T00:00:00.000Z', + acpSessionConfig: { + prompt: `task ${id}`, + cliType: 'builtin', + agentType: 'codex', + }, + }) + ); + const history: SessionHistoryInput[] = []; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + consumeMessageQueueItemAsUserTurn: vi.fn( + async ( + cid: string, + buildEntry: (item: MessageQueueItem) => SessionHistoryInput | null + ) => { + const index = queue.findIndex((item) => item.$cid === cid); + if (index < 0) return { type: 'missing' as const }; + const entry = buildEntry(queue[index]!); + if (!entry) return { type: 'invalid' as const }; + history.push(entry); + queue.splice(index, 1); + return { type: 'consumed' as const, entry }; + } + ), + }; + const deps = createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { + turnRuntimeBySession: Map; + } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession').mockResolvedValue({ success: true }); + + await expect( + service.steerQueuedMessage({ + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + requestedByUserId: 'owner-user', + }) + ).resolves.toMatchObject({ + accepted: true, + disposition: 'accepted', + queueItemId: 'C', + userTurnId: 'user:C', + }); + + expect(queue.map((item) => item.$cid)).toEqual(['A', 'B']); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ id: 'user:C', role: 'user' }); + expect(cancel).toHaveBeenCalledWith(expect.objectContaining({ turnId: activeTurnId })); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn.mock.invocationCallOrder[0]).toBeLessThan( + cancel.mock.invocationCallOrder[0]! + ); + }); + + it('keeps the active turn running when the selected queue identity is gone', async () => { + const sessionId = 'session-queue-steer-missing' as SessionId; + const activeTurnId = 'assistant:active'; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + consumeMessageQueueItemAsUserTurn: vi.fn(async () => ({ type: 'missing' as const })), + }; + const deps = createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { + turnRuntimeBySession: Map; + } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession').mockResolvedValue({ success: true }); + + await expect( + service.steerQueuedMessage({ + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + requestedByUserId: 'owner-user', + }) + ).resolves.toMatchObject({ + accepted: false, + disposition: 'queue-item-missing', + queueItemId: 'C', + }); + expect(cancel).not.toHaveBeenCalled(); + expect( + (service as unknown as { turnRuntimeBySession: Map }).turnRuntimeBySession.get( + sessionId + ) + ).toBe(runtime); + }); it('advances one session owner through consecutive prompt handoffs', async () => { const steerPrompt = vi.fn(() => ({ completion: new Promise(() => {}), diff --git a/locales/en.json b/locales/en.json index d4b547342..3e45dd222 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1981,6 +1981,7 @@ "sessions.proposedPlanDecision.executing": "Implementing plan...", "sessions.queueEditError": "Failed to edit message", "sessions.queueError": "Failed to queue message", + "sessions.queueItemMissing": "This queued message is no longer available", "sessions.queueRemoveError": "Failed to remove message from queue", "sessions.queueReorderError": "Failed to reorder messages", "sessions.removeImage": "Remove image", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 0e7646135..ec4dbc36d 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1981,6 +1981,7 @@ "sessions.proposedPlanDecision.executing": "正在实施计划...", "sessions.queueEditError": "编辑消息失败", "sessions.queueError": "消息入队失败", + "sessions.queueItemMissing": "这条排队消息已不存在", "sessions.queueRemoveError": "移除消息失败", "sessions.queueReorderError": "调整消息顺序失败", "sessions.removeImage": "移除图片", diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index b292518d1..6addaec46 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -16,6 +16,7 @@ import type { SessionPreparationSpec, SessionPrepareCancelResponse, SessionPrepareResponse, + SessionQueueSteerResponse, SessionSteerResponse, SessionGoalAction, SessionGoalResponse, @@ -302,6 +303,16 @@ export type WorkspaceRuntime = { }, options?: { timeoutMs?: number } ) => Promise; + requestSessionQueueSteer: ( + machineId: MachineId, + args: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + requestedByUserId: string; + }, + options?: { timeoutMs?: number } + ) => Promise; requestSessionGoal: ( machineId: MachineId, args: { diff --git a/packages/components/src/components/sessions/message-queue/AGENTS.md b/packages/components/src/components/sessions/message-queue/AGENTS.md index e9d1cd7a5..d70efcb8f 100644 --- a/packages/components/src/components/sessions/message-queue/AGENTS.md +++ b/packages/components/src/components/sessions/message-queue/AGENTS.md @@ -9,15 +9,11 @@ queued-turn list (`message-queue-display.tsx`, `message-queue-row.tsx`, `../session-message-submit-route.ts` and is described in [.agents/docs/sessions-live-status.md](../../../../../../.agents/docs/sessions-live-status.md). -A queued item's Steer action uses native acknowledged steering only when the -authoritative ACP capability cache advertises it. Never infer steering support -from built-in/custom config type or agent identity; unsupported and stale cache -entries retain the interrupt-and-send fallback. - -Every queued row offers Steer while an active turn can accept it. The compatibility -fallback must move a selected later row to the queue head before interrupting, and -must not interrupt if that reorder fails. A row's number and message body are one -drag activator; its Steer, edit, and remove controls stay outside that activator. +Every queued row offers Steer while an active turn can accept it. Steer sends the +selected `$cid` and expected active turn to the owning daemon; it never reorders, +removes, or materializes a queue row in the renderer. A rejected or missing identity +must leave the active turn running. A row's number and message body are one drag +activator; its Steer, edit, and remove controls stay outside that activator. The queue intentionally stays OUT of the composer info bar ([.agents/docs/sessions-info-bar.md](../../../../../../.agents/docs/sessions-info-bar.md)). diff --git a/packages/components/src/components/sessions/message-queue/index.ts b/packages/components/src/components/sessions/message-queue/index.ts index 4f924c2df..078978312 100644 --- a/packages/components/src/components/sessions/message-queue/index.ts +++ b/packages/components/src/components/sessions/message-queue/index.ts @@ -2,11 +2,6 @@ export { MessageQueueDisplay } from './message-queue-display'; export type { MessageQueueDisplayProps } from './message-queue-display'; export { MessageQueueRow } from './message-queue-row'; export type { MessageQueueRowProps } from './message-queue-row'; -export { - resolveFallbackQueueSteerPreparation, - steerQueuedMessageWithFallback, - shouldRequestNativeQueueSteer, -} from './queued-message-steer'; export { QueuedImagePreview } from './queued-image-preview'; export type { QueuedImageBlock } from './queued-image-preview'; export { diff --git a/packages/components/src/components/sessions/message-queue/queued-message-steer.ts b/packages/components/src/components/sessions/message-queue/queued-message-steer.ts deleted file mode 100644 index 589adc20f..000000000 --- a/packages/components/src/components/sessions/message-queue/queued-message-steer.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AcpCapabilityAuthority, AcpCapabilityCacheEntry } from '@lody/shared'; - -export type FallbackQueueSteerPreparation = - | { type: 'ready' } - | { type: 'reorder'; activeCid: string; overCid: string } - | { type: 'missing' }; - -export function resolveFallbackQueueSteerPreparation( - queueItemCids: readonly string[], - selectedCid: string -): FallbackQueueSteerPreparation { - const selectedIndex = queueItemCids.indexOf(selectedCid); - if (selectedIndex < 0) return { type: 'missing' }; - if (selectedIndex === 0) return { type: 'ready' }; - return { - type: 'reorder', - activeCid: selectedCid, - overCid: queueItemCids[0]!, - }; -} - -export async function steerQueuedMessageWithFallback({ - queueItemCids, - selectedCid, - reorder, - interrupt, -}: { - queueItemCids: readonly string[]; - selectedCid: string; - reorder: (activeCid: string, overCid: string) => Promise; - interrupt: () => Promise; -}): Promise<'steered' | 'missing' | 'reorder_failed'> { - const preparation = resolveFallbackQueueSteerPreparation(queueItemCids, selectedCid); - if (preparation.type === 'missing') return 'missing'; - if (preparation.type === 'reorder') { - try { - await reorder(preparation.activeCid, preparation.overCid); - } catch { - return 'reorder_failed'; - } - } - await interrupt(); - return 'steered'; -} - -export function shouldRequestNativeQueueSteer( - authority: AcpCapabilityAuthority, - capability: Pick | undefined -): boolean { - return authority === 'authoritative' && capability?.acknowledgedSteer === true; -} diff --git a/packages/components/src/components/sessions/session-chat-input-area.tsx b/packages/components/src/components/sessions/session-chat-input-area.tsx index 36c87b01d..972c3ccca 100644 --- a/packages/components/src/components/sessions/session-chat-input-area.tsx +++ b/packages/components/src/components/sessions/session-chat-input-area.tsx @@ -474,7 +474,7 @@ export interface SessionChatInputAreaProps { export type SessionTurnAgentRoleSelection = ComposerTurnAgentRoleSelection; export type SessionChatInputSubmitOptions = { - invertQueuedBehavior?: boolean; + queueBehavior?: 'inverse'; }; export type SessionChatInputAreaHandle = { @@ -1762,10 +1762,7 @@ export const SessionChatInputArea = memo( [pastedTextDrafts, session.id, updatePastedTextDraftsForSession] ); - const invertQueuedBehaviorForNextSubmitRef = useRef(false); - const sendMessage = useCallback(async () => { - const invertQueuedBehavior = invertQueuedBehaviorForNextSubmitRef.current; - invertQueuedBehaviorForNextSubmitRef.current = false; + const sendMessage = useCallback(async (options?: SessionChatInputSubmitOptions) => { if (freeTurnLimitNotice && freeTurnLimitNotice.current >= freeTurnLimitNotice.limit) { capturePostHogEvent(postHog, 'session/input_blocked', { reason: 'free_session_turn_limit_reached', @@ -1912,7 +1909,7 @@ export const SessionChatInputArea = memo( const accepted = await onSendMessage( inputBlocks, agentRoleTurnSelectionRef.current, - invertQueuedBehavior ? { invertQueuedBehavior: true } : undefined + options ); if (accepted) { if (submission.isCurrent()) { @@ -2022,10 +2019,17 @@ export const SessionChatInputArea = memo( category: 'Session', keybindings: getCommandKeybindings('session.sendWithInverseQueueBehavior'), allowInTextInput: true, - when: () => hasSendableContent && !isSendActionDisabled, + when: () => { + const activeElement = document.activeElement; + return ( + activeElement instanceof Element && + activeElement.closest('[data-lody-composer-input]') !== null && + hasSendableContent && + !isSendActionDisabled + ); + }, run: () => { - invertQueuedBehaviorForNextSubmitRef.current = true; - void sendMessage(); + void sendMessage({ queueBehavior: 'inverse' }); }, }, commandsEnabled diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index de922c51e..21aa67e52 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -66,11 +66,7 @@ import { type SessionTurnAgentRoleSelection, } from './session-chat-input-area'; import { useSessionMcpSelection } from '@/hooks/use-session-mcp-selection'; -import { - MessageQueueDisplay, - shouldRequestNativeQueueSteer, - steerQueuedMessageWithFallback, -} from './message-queue'; +import { MessageQueueDisplay } from './message-queue'; import { useTranslation } from 'react-i18next'; import { useRouter } from '@tanstack/react-router'; import { toast } from 'sonner'; @@ -1911,7 +1907,7 @@ export type SessionChatInterfaceHandle = { export type DispatchInputBlocksOptions = { forceQueue?: boolean; forceDirect?: boolean; - invertQueuedBehavior?: boolean; + queueBehavior?: 'inverse'; modeIdOverride?: string | null; modelIdOverride?: string | null; configOptionValuesOverride?: Record; @@ -2410,6 +2406,7 @@ export const SessionChatInterface = memo( requestSessionCancel, requestSessionDispatch, requestSessionGoal, + requestSessionQueueSteer, requestSessionSteer, touchSessionActivity, transferSessionOwner, @@ -3956,7 +3953,7 @@ export const SessionChatInterface = memo( const submitRoute = resolveSessionMessageSubmitRoute({ forceDirect, forceQueue: options?.forceQueue === true, - invertQueuedBehavior: options?.invertQueuedBehavior === true, + queueBehavior: options?.queueBehavior, isPromptBusy: isAgentBusy, hasUnfinishedAssistantTurn: activeAssistantTurnId != null, queuedMessageBehavior, @@ -3983,7 +3980,7 @@ export const SessionChatInterface = memo( ...inputSummary, force_queue: Boolean(options?.forceQueue), force_direct: forceDirect, - invert_queued_behavior: Boolean(options?.invertQueuedBehavior), + queue_behavior_override: options?.queueBehavior ?? 'default', submit_route: submitRoute.type, is_agent_busy: isAgentBusy, mode_id: turnModeId ?? null, @@ -4108,7 +4105,7 @@ export const SessionChatInterface = memo( async ( inputBlocks: SessionInputBlock[], agentRole?: SessionTurnAgentRoleSelection, - options?: Pick + options?: Pick ): Promise => { return await dispatchInputBlocks(inputBlocks, { agentRole, ...options }); }, @@ -5157,109 +5154,71 @@ export const SessionChatInterface = memo( workspaceId, ]); - const handleInterruptAndSend = useCallback( - async (item: MessageQueueItem) => { - if (isExternalHistoryRefreshing) { - captureSessionEvent('session/queue_interrupt_blocked', { - reason: 'external_history_syncing', - queue_item_id: item.$cid, - }); - return; - } - if (!workspaceId || !activeAssistantTurnId) { - captureSessionEvent('session/queue_interrupt_blocked', { - reason: !workspaceId ? 'missing_workspace' : 'missing_active_turn', - queue_item_id: item.$cid, - }); - toast.error(t('sessions.interruptFailed', 'Failed to interrupt current task')); - return; - } - setInputActionState('ready'); - pendingUserInterruptRef.current = true; + const handleReorderQueueItem = useCallback( + async (activeCid: string, overCid: string) => { try { - await requestSessionCancel(session.id, activeAssistantTurnId); - captureSessionEvent('session/queue_interrupt_succeeded', { - queue_item_id: item.$cid, - active_assistant_turn_id: activeAssistantTurnId, + captureSessionEvent('session/queue_item_reorder_requested', { + queue_item_id: activeCid, + over_queue_item_id: overCid, + }); + await reorderMessageQueueItem(activeCid, overCid); + captureSessionEvent('session/queue_item_reordered', { + queue_item_id: activeCid, + over_queue_item_id: overCid, }); } catch (error) { - console.error('Failed to interrupt for queued message', error); - pendingUserInterruptRef.current = false; - captureSessionEvent('session/queue_interrupt_failed', { - queue_item_id: item.$cid, - active_assistant_turn_id: activeAssistantTurnId, + console.error('Failed to reorder queued message', error); + captureSessionEvent('session/queue_item_reorder_failed', { + queue_item_id: activeCid, + over_queue_item_id: overCid, error_name: error instanceof Error ? error.name : typeof error, error_message: getErrorMessage(error), }); - toast.error(t('sessions.interruptFailed', 'Failed to interrupt current task'), { + toast.error(t('sessions.queueReorderError', 'Failed to reorder messages'), { description: getErrorMessage(error), }); } }, - [ - activeAssistantTurnId, - captureSessionEvent, - isExternalHistoryRefreshing, - requestSessionCancel, - session.id, - t, - workspaceId, - ] + [captureSessionEvent, reorderMessageQueueItem, t] ); - const handleNativeSteerQueuedMessage = useCallback( + const handleSteerQueuedMessage = useCallback( async (item: MessageQueueItem) => { - if (isExternalHistoryRefreshing || !activeAssistantTurnId) { - return; - } - if (steeringQueueItemIdsRef.current.has(item.$cid)) { + if (isExternalHistoryRefreshing || !activeAssistantTurnId || !currentUser?.id) { return; } + if (steeringQueueItemIdsRef.current.has(item.$cid)) return; steeringQueueItemIdsRef.current.add(item.$cid); try { - const inputConfig = normalizeSessionTurnInputConfig(item.acpSessionConfig); - if (!inputConfig) { - throw new Error('Queued message input config is invalid'); - } - const inputBlocks = normalizeSessionInputBlocks( - inputConfig.inputBlocks, - inputConfig.prompt ?? item.task + const response = await requestSessionQueueSteer( + session.id, + activeAssistantTurnId, + item.$cid, + { machineId: session.machineId, requestedByUserId: currentUser.id } ); - const pendingHistoryEntry = buildPendingUserHistoryEntry({ - userId: item.userId, - inputBlocks, - timestamp: item.timestamp, - inputConfig, - status: 'pending_apply', - }); - if (!pendingHistoryEntry) { - throw new Error('Queued message is empty'); - } - const queuedUserTurnId = item.userTurnId?.trim() || `queued-${item.$cid}`; - const { entry: historyEntry } = await addSessionHistory({ - ...pendingHistoryEntry, - id: queuedUserTurnId, - }); - await removeMessageQueueItem(item.$cid); - trackMessageSend(historyEntry.id); - touchSessionActivity(session.id).catch((error: unknown) => { - console.warn('Failed to update session activity for steer', error); - }); - const applied = await guideHistoryEntry(historyEntry.id, activeAssistantTurnId); - captureSessionEvent('session/queue_guide_result', { + captureSessionEvent('session/queue_steer_result', { queue_item_id: item.$cid, active_assistant_turn_id: activeAssistantTurnId, - applied, + accepted: response?.accepted === true, + disposition: response?.disposition ?? 'no_response', }); + if (!response?.accepted) { + toast.error(t('sessions.interruptFailed', 'Failed to interrupt current task'), { + description: + response?.disposition === 'queue-item-missing' + ? t('sessions.queueItemMissing', 'That queued message is no longer available') + : response?.error, + }); + } } catch (error) { - console.error('Failed to guide with queued message', error); - captureSessionEvent('session/queue_guide_failed', { + console.error('Failed to steer queued message', error); + captureSessionEvent('session/queue_steer_failed', { queue_item_id: item.$cid, active_assistant_turn_id: activeAssistantTurnId, error_name: error instanceof Error ? error.name : typeof error, error_message: getErrorMessage(error), }); - toast.error(t('sessions.sendError'), { + toast.error(t('sessions.interruptFailed', 'Failed to interrupt current task'), { description: getErrorMessage(error), }); } finally { @@ -5268,77 +5227,13 @@ export const SessionChatInterface = memo( }, [ activeAssistantTurnId, - addSessionHistory, captureSessionEvent, - guideHistoryEntry, + currentUser?.id, isExternalHistoryRefreshing, - removeMessageQueueItem, + requestSessionQueueSteer, session.id, + session.machineId, t, - touchSessionActivity, - trackMessageSend, - ] - ); - - const queueSteerCapability = session.agentConfigId - ? sessionMachine?.acpCapabilities?.[getAcpCapabilityCacheKey(session.agentConfigId)] - : undefined; - const shouldUseNativeQueueSteer = shouldRequestNativeQueueSteer( - capabilityAuthority, - queueSteerCapability - ); - const handleReorderQueueItem = useCallback( - async (activeCid: string, overCid: string) => { - try { - captureSessionEvent('session/queue_item_reorder_requested', { - queue_item_id: activeCid, - over_queue_item_id: overCid, - }); - await reorderMessageQueueItem(activeCid, overCid); - captureSessionEvent('session/queue_item_reordered', { - queue_item_id: activeCid, - over_queue_item_id: overCid, - }); - } catch (error) { - console.error('Failed to reorder queued message', error); - captureSessionEvent('session/queue_item_reorder_failed', { - queue_item_id: activeCid, - over_queue_item_id: overCid, - error_name: error instanceof Error ? error.name : typeof error, - error_message: getErrorMessage(error), - }); - toast.error(t('sessions.queueReorderError', 'Failed to reorder messages'), { - description: getErrorMessage(error), - }); - throw error; - } - }, - [captureSessionEvent, reorderMessageQueueItem, t] - ); - - const handleSteerQueuedMessage = useCallback( - async (item: MessageQueueItem) => { - if (shouldUseNativeQueueSteer) { - await handleNativeSteerQueuedMessage(item); - return; - } - - // The compatibility path cancels the active turn and lets the daemon promote - // the queue head. Move an explicitly selected later item to that position first, - // preserving the user's choice without creating a second dispatch path. - await steerQueuedMessageWithFallback({ - queueItemCids: messageQueue.map((queuedItem) => queuedItem.$cid), - selectedCid: item.$cid, - reorder: handleReorderQueueItem, - interrupt: () => handleInterruptAndSend(item), - }); - }, - [ - handleInterruptAndSend, - handleNativeSteerQueuedMessage, - handleReorderQueueItem, - messageQueue, - shouldUseNativeQueueSteer, ] ); diff --git a/packages/components/src/components/sessions/session-message-submit-route.ts b/packages/components/src/components/sessions/session-message-submit-route.ts index 7ce93d2bb..f86361ef0 100644 --- a/packages/components/src/components/sessions/session-message-submit-route.ts +++ b/packages/components/src/components/sessions/session-message-submit-route.ts @@ -6,7 +6,7 @@ export type SessionMessageSubmitRoute = export type SessionMessageSubmitRouteInput = { forceDirect: boolean; forceQueue: boolean; - invertQueuedBehavior: boolean; + queueBehavior?: 'inverse'; isPromptBusy: boolean; hasUnfinishedAssistantTurn: boolean; queuedMessageBehavior: 'queue' | 'guide'; @@ -24,7 +24,7 @@ export type SessionMessageSubmitRouteInput = { export function resolveSessionMessageSubmitRoute({ forceDirect, forceQueue, - invertQueuedBehavior, + queueBehavior, isPromptBusy, hasUnfinishedAssistantTurn, queuedMessageBehavior, @@ -32,7 +32,7 @@ export function resolveSessionMessageSubmitRoute({ if (forceDirect) { return { type: 'direct_dispatch' }; } - const effectiveQueuedMessageBehavior = invertQueuedBehavior + const effectiveQueuedMessageBehavior = queueBehavior === 'inverse' ? queuedMessageBehavior === 'queue' ? 'guide' : 'queue' diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index eb5b49347..073d70260 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -17,6 +17,7 @@ import type { MachineFlockKey, SessionGoalAction, SessionGoalResponse, + SessionQueueSteerResponse, } from '@lody/shared'; import { getMachineRoomId, @@ -301,6 +302,12 @@ export type SessionActions = { userTurnId: string, options?: { machineId?: MachineId | null } ) => Promise; + requestSessionQueueSteer: ( + sessionId: SessionId, + expectedTurnId: string, + queueItemId: string, + options: { requestedByUserId: string; machineId?: MachineId | null } + ) => Promise; /** * Run a goal action through the agent's control extension. * @@ -957,6 +964,39 @@ export function useSessionActions(): SessionActions { [requestSessionDispatch, runtime, store] ); + const requestSessionQueueSteer = useCallback( + async ( + sessionId: SessionId, + expectedTurnId: string, + queueItemId: string, + options: { requestedByUserId: string; machineId?: MachineId | null } + ): Promise => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + const roomId = getSessionRoomId(sessionId); + let machineId = options.machineId ?? null; + if (!machineId) { + const existing = await runtime.repo.getDocMeta(roomId); + const meta = isLoroRepoDocDeleted(existing) + ? undefined + : (existing?.meta as SessionMeta | undefined); + machineId = meta?.machineId ?? null; + } + const requestedByUserId = options.requestedByUserId.trim(); + if (!machineId || !requestedByUserId) { + return null; + } + return await runtime.requestSessionQueueSteer(machineId, { + sessionId, + expectedTurnId, + queueItemId, + requestedByUserId, + }); + }, + [runtime] + ); + const touchSessionActivity = useCallback( async (sessionId: SessionId) => { if (!runtime) { @@ -1291,6 +1331,7 @@ export function useSessionActions(): SessionActions { addSessionHistory, requestSessionDispatch, requestSessionCancel, + requestSessionQueueSteer, requestSessionSteer, requestSessionGoal, touchSessionActivity, diff --git a/packages/components/src/lib/commands/shortcuts.ts b/packages/components/src/lib/commands/shortcuts.ts index 0a674f39b..461bc373f 100644 --- a/packages/components/src/lib/commands/shortcuts.ts +++ b/packages/components/src/lib/commands/shortcuts.ts @@ -68,7 +68,7 @@ export const COMMAND_SHORTCUTS: Record = // Desktop ⌘L focuses the composer. On web the browser owns ⌘L (Open Location), // so leave it unbound — the hint chip follows the resolved binding. 'session.focusInput': [electron('Mod+l')], - 'session.sendWithInverseQueueBehavior': [whileComposerFocused('Mod+Shift+Enter')], + 'session.sendWithInverseQueueBehavior': ['Mod+Shift+Enter'], 'session.toggleExplorerSidebar': ['Mod+Alt+b'], 'session.copyCurrentBranch': ['Alt+Shift+b'], 'session.copyUrl': ['Alt+Shift+c'], diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 9a9d448de..ac4e7acc9 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -1679,6 +1679,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise => { + try { + if (await canUseLocalMachineRpc(machineId)) { + const response = await getLocalMachineRpcSender()?.({ + machineId, + workspaceId, + method: 'session/queue-steer', + params: args, + timeoutMs: options?.timeoutMs ?? 5_000, + }); + if (response && !response.ok) { + return { + type: 'session/queue-steer_response', + sessionId: args.sessionId, + queueItemId: args.queueItemId, + accepted: false, + disposition: 'error', + error: response.error, + }; + } + if (response?.ok) return response.result as SessionQueueSteerResponse; + } + return await ( + await getMachineRpcClient(machineId) + ).requestSessionQueueSteer({ + ...args, + timeoutMs: options?.timeoutMs ?? 5_000, + }); + } catch (error) { + return { + type: 'session/queue-steer_response', + sessionId: args.sessionId, + queueItemId: args.queueItemId, + accepted: false, + disposition: 'error', + error: error instanceof Error ? error.message : String(error), + }; + } + }; + const requestSessionGoal = async ( machineId: MachineId, args: { @@ -1173,6 +1223,7 @@ export function createWorkspaceMachineRpcFacade(deps: WorkspaceMachineRpcFacadeD return { requestSessionCancel, + requestSessionQueueSteer, requestSessionSteer, requestSessionGoal, requestSessionTerminate, diff --git a/packages/components/tests/queued-message-steer.test.ts b/packages/components/tests/queued-message-steer.test.ts deleted file mode 100644 index 542bf1d04..000000000 --- a/packages/components/tests/queued-message-steer.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - resolveFallbackQueueSteerPreparation, - shouldRequestNativeQueueSteer, - steerQueuedMessageWithFallback, -} from '../src/components/sessions/message-queue/queued-message-steer'; - -describe('shouldRequestNativeQueueSteer', () => { - it.each([ - ['authoritative', { acknowledgedSteer: true }, true], - ['authoritative', { acknowledgedSteer: false }, false], - ['authoritative', undefined, false], - ['provisional', { acknowledgedSteer: true }, false], - ['unavailable', { acknowledgedSteer: true }, false], - ] as const)('routes %s capability %o to native steer: %s', (authority, capability, expected) => { - expect(shouldRequestNativeQueueSteer(authority, capability)).toBe(expected); - }); -}); - -describe('resolveFallbackQueueSteerPreparation', () => { - it('moves a selected later item to the queue head before interrupting', () => { - expect(resolveFallbackQueueSteerPreparation(['first', 'second', 'third'], 'third')).toEqual({ - type: 'reorder', - activeCid: 'third', - overCid: 'first', - }); - }); - - it('leaves the queue head in place and rejects stale selections', () => { - expect(resolveFallbackQueueSteerPreparation(['first', 'second'], 'first')).toEqual({ - type: 'ready', - }); - expect(resolveFallbackQueueSteerPreparation(['first', 'second'], 'missing')).toEqual({ - type: 'missing', - }); - }); -}); - -describe('steerQueuedMessageWithFallback', () => { - it('reorders a later selection before interrupting', async () => { - const events: string[] = []; - const result = await steerQueuedMessageWithFallback({ - queueItemCids: ['first', 'second'], - selectedCid: 'second', - reorder: async (activeCid, overCid) => { - events.push(`reorder:${activeCid}:${overCid}`); - }, - interrupt: async () => { - events.push('interrupt'); - }, - }); - - expect(result).toBe('steered'); - expect(events).toEqual(['reorder:second:first', 'interrupt']); - }); - - it('does not interrupt when reordering fails', async () => { - const events: string[] = []; - const result = await steerQueuedMessageWithFallback({ - queueItemCids: ['first', 'second'], - selectedCid: 'second', - reorder: async () => { - events.push('reorder'); - throw new Error('reorder failed'); - }, - interrupt: async () => { - events.push('interrupt'); - }, - }); - - expect(result).toBe('reorder_failed'); - expect(events).toEqual(['reorder']); - }); -}); diff --git a/packages/components/tests/session-chat-input-submission.test.tsx b/packages/components/tests/session-chat-input-submission.test.tsx index ba4a717c1..edc421553 100644 --- a/packages/components/tests/session-chat-input-submission.test.tsx +++ b/packages/components/tests/session-chat-input-submission.test.tsx @@ -406,7 +406,12 @@ describe('SessionChatInputArea submission feedback', () => { it('submits the current draft with one-shot inverse queue behavior', async () => { const onSendMessage = vi.fn(async () => true); - await renderComposer({ sessionId: 'inverse-queue-submit', onSendMessage }); + const textarea = await renderComposer({ sessionId: 'inverse-queue-submit', onSendMessage }); + + document.body.tabIndex = -1; + document.body.focus(); + expect(commands.execute('session.sendWithInverseQueueBehavior')).toBe(false); + textarea.focus(); await act(async () => { expect(commands.execute('session.sendWithInverseQueueBehavior')).toBe(true); @@ -414,7 +419,7 @@ describe('SessionChatInputArea submission feedback', () => { }); expect(onSendMessage).toHaveBeenCalledOnce(); - expect(onSendMessage.mock.calls[0]?.[2]).toEqual({ invertQueuedBehavior: true }); + expect(onSendMessage.mock.calls[0]?.[2]).toEqual({ queueBehavior: 'inverse' }); }); it.each([ diff --git a/packages/components/tests/session-message-submit-route.test.ts b/packages/components/tests/session-message-submit-route.test.ts index fccaba37f..dfec90f01 100644 --- a/packages/components/tests/session-message-submit-route.test.ts +++ b/packages/components/tests/session-message-submit-route.test.ts @@ -5,7 +5,7 @@ const resolve = (overrides: Partial { isPromptBusy: true, hasUnfinishedAssistantTurn: true, queuedMessageBehavior: 'queue', - invertQueuedBehavior: true, + queueBehavior: 'inverse', }) ).toEqual({ type: 'guide' }); expect( @@ -57,7 +57,7 @@ describe('resolveSessionMessageSubmitRoute', () => { isPromptBusy: true, hasUnfinishedAssistantTurn: true, queuedMessageBehavior: 'guide', - invertQueuedBehavior: true, + queueBehavior: 'inverse', }) ).toEqual({ type: 'queue', reason: 'prompt_busy' }); }); diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index 6bb045c42..ec62002b5 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -351,6 +351,37 @@ describe('useSessionActions', () => { return actions; }; + it('forwards both queue and active-turn identities for queued Steer', async () => { + const sessionId = 'session-queue-steer' as SessionId; + const machineId = 'machine-1' as MachineId; + const requestSessionQueueSteer = vi.fn(async () => ({ + type: 'session/queue-steer_response' as const, + sessionId, + queueItemId: 'queue-C', + userTurnId: 'user-C', + accepted: true, + disposition: 'accepted' as const, + })); + const runtime = createRuntime({}) as WorkspaceRuntime & { + requestSessionQueueSteer: WorkspaceRuntime['requestSessionQueueSteer']; + }; + runtime.requestSessionQueueSteer = requestSessionQueueSteer; + const actions = await renderActions(runtime); + + await expect( + actions.requestSessionQueueSteer(sessionId, 'assistant-active', 'queue-C', { + machineId, + requestedByUserId: 'user-1', + }) + ).resolves.toMatchObject({ accepted: true, queueItemId: 'queue-C' }); + expect(requestSessionQueueSteer).toHaveBeenCalledWith(machineId, { + sessionId, + expectedTurnId: 'assistant-active', + queueItemId: 'queue-C', + requestedByUserId: 'user-1', + }); + }); + it('does not block session creation on remote stream pre-creation', async () => { const sessionId = 'session-create-stream-pending' as SessionId; const streamCreate = createDeferred(); diff --git a/packages/components/tests/workspace-machine-rpc-facade.test.ts b/packages/components/tests/workspace-machine-rpc-facade.test.ts index 8b544963f..c8fb21957 100644 --- a/packages/components/tests/workspace-machine-rpc-facade.test.ts +++ b/packages/components/tests/workspace-machine-rpc-facade.test.ts @@ -17,6 +17,58 @@ afterEach(() => { }); describe('createWorkspaceMachineRpcFacade', () => { + it('sends queued Steer as one exact local daemon operation', async () => { + const invoke = vi.fn(async () => ({ + ok: true as const, + result: { + type: 'session/queue-steer_response' as const, + sessionId, + queueItemId: 'queue-C', + userTurnId: 'user-C', + accepted: true, + disposition: 'accepted' as const, + }, + })); + vi.stubGlobal('window', { + __LODY_ELECTRON__: true, + ipc: { invoke }, + }); + const getMachineRpcClient = vi.fn(); + const facade = createWorkspaceMachineRpcFacade({ + workspaceId, + getMachineProtocolCapabilities: async () => CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + targetRouter: { + getPlaneForMachine: () => 'local', + resolvePlaneForMachine: vi.fn(async () => 'local'), + }, + getMachineRpcClient, + }); + + await expect( + facade.requestSessionQueueSteer(localMachineId, { + sessionId, + expectedTurnId: 'assistant-active', + queueItemId: 'queue-C', + requestedByUserId: 'user-1', + }) + ).resolves.toMatchObject({ accepted: true, queueItemId: 'queue-C' }); + expect(invoke).toHaveBeenCalledWith( + 'machineRpc.send', + expect.objectContaining({ + machineId: localMachineId, + workspaceId, + method: 'session/queue-steer', + params: { + sessionId, + expectedTurnId: 'assistant-active', + queueItemId: 'queue-C', + requestedByUserId: 'user-1', + }, + }) + ); + expect(getMachineRpcClient).not.toHaveBeenCalled(); + }); + it('never sends a scoped cancel to a daemon without the scoped-cancel protocol', async () => { const facade = createWorkspaceMachineRpcFacade({ workspaceId, diff --git a/packages/loro-streams-rpc/AGENTS.md b/packages/loro-streams-rpc/AGENTS.md index c64b4508a..d3d961d8e 100644 --- a/packages/loro-streams-rpc/AGENTS.md +++ b/packages/loro-streams-rpc/AGENTS.md @@ -46,7 +46,7 @@ and the `file/preview` namespace are in needs read-check-write atomicity serializes in its own service layer (Code Collab `save-text` per absolute path in `code-collab-v2-service.ts`), not in the request loop. - Control-plane methods (`machine/status`, `machine/ping`, `session/cancel`, - `session/live-status`, `session/steer`, `session/terminate`, `machine/restart`, + `session/live-status`, `session/queue-steer`, `session/steer`, `session/terminate`, `machine/restart`, `machine/upgrade`, `session/dispatch-turn`) bypass the shared semaphore and run on a small dedicated lane (`CONTROL_METHODS` in `machine-rpc-server.ts`) so saturated code-collab diff --git a/packages/loro-streams-rpc/src/machine-rpc-server.ts b/packages/loro-streams-rpc/src/machine-rpc-server.ts index f45ae91cd..a06a46adb 100644 --- a/packages/loro-streams-rpc/src/machine-rpc-server.ts +++ b/packages/loro-streams-rpc/src/machine-rpc-server.ts @@ -34,6 +34,7 @@ import type { PreviewTarget, PreviewTargetApproval, SessionCancelResponse, + SessionQueueSteerResponse, SessionPreparationCancelSpec, SessionPreparationSpec, SessionPrepareCancelResponse, @@ -118,6 +119,7 @@ const CONTROL_METHODS: ReadonlySet = new Set([ 'machine/acp-capabilities-refresh-cancel', 'session/cancel', 'session/live-status', + 'session/queue-steer', 'session/steer', 'session/goal', 'session/terminate', @@ -350,6 +352,12 @@ type RpcServerDeps = { getSessionLiveStatus?: (args: { sessionId: SessionId; }) => Promise; + steerQueuedMessage?: (args: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + requestedByUserId: string; + }) => Promise; steerSession?: (args: { sessionId: SessionId; expectedTurnId: string; @@ -1107,6 +1115,23 @@ export class LoroStreamsMachineRpcServer { await this.appendResultResponse(request.replyTo, request.id, request.method, response); return; } + case 'session/queue-steer': { + if (!this.deps.steerQueuedMessage) { + await this.appendErrorResponse(request.replyTo, request.id, request.method, { + code: LORO_STREAMS_RPC_ERROR_CODES.methodUnavailable, + message: 'Queued message steer is not available on this machine.', + }); + return; + } + const response = await this.deps.steerQueuedMessage({ + sessionId: request.params.sessionId as SessionId, + expectedTurnId: request.params.expectedTurnId, + queueItemId: request.params.queueItemId, + requestedByUserId: request.params.requestedByUserId, + }); + await this.appendResultResponse(request.replyTo, request.id, request.method, response); + return; + } case 'session/goal': { if (!this.deps.controlSessionGoal) { await this.appendErrorResponse(request.replyTo, request.id, request.method, { @@ -1615,6 +1640,7 @@ export class LoroStreamsMachineRpcServer { | MachineBugReportResponse | SessionCancelResponse | LoroSessionLiveStatusRpcResponse + | SessionQueueSteerResponse | SessionSteerResponse | SessionGoalResponse | SessionTerminateResponse diff --git a/packages/loro-streams-rpc/src/rpc.ts b/packages/loro-streams-rpc/src/rpc.ts index 9d93870cd..cdd7e6d68 100644 --- a/packages/loro-streams-rpc/src/rpc.ts +++ b/packages/loro-streams-rpc/src/rpc.ts @@ -45,6 +45,7 @@ import type { MachineStatusResponse, MachineUpgradeResponse, SessionCancelResponse, + SessionQueueSteerResponse, SessionGoalAction, SessionGoalResponse, SessionPreparationCancelSpec, @@ -94,6 +95,7 @@ import { MachineStatusResponseSchema, MachineUpgradeResponseSchema, SessionCancelResponseSchema, + SessionQueueSteerResponseSchema, SessionPreparationCancelSpecSchema, SessionPreparationSpecSchema, SessionPrepareCancelResponseSchema, @@ -192,6 +194,7 @@ export const LoroStreamsRpcMethodSchema = z.enum([ 'file/preview', 'session/cancel', 'session/live-status', + 'session/queue-steer', 'session/steer', 'session/goal', 'session/terminate', @@ -472,6 +475,18 @@ export const LoroSessionSteerRpcRequestSchema = BaseRpcRequestSchema.extend({ .strict(), }).strict(); +export const LoroSessionQueueSteerRpcRequestSchema = BaseRpcRequestSchema.extend({ + method: z.literal('session/queue-steer'), + params: z + .object({ + sessionId: SessionIdSchema, + expectedTurnId: z.string().trim().min(1), + queueItemId: z.string().trim().min(1), + requestedByUserId: z.string().trim().min(1), + }) + .strict(), +}).strict(); + export const LoroSessionTerminateRpcRequestSchema = BaseRpcRequestSchema.extend({ method: z.literal('session/terminate'), params: z @@ -599,6 +614,7 @@ export const LoroStreamsRpcRequestSchema = z.discriminatedUnion('method', [ LoroFilePreviewRpcRequestSchema, LoroSessionCancelRpcRequestSchema, LoroSessionLiveStatusRpcRequestSchema, + LoroSessionQueueSteerRpcRequestSchema, LoroSessionSteerRpcRequestSchema, LoroSessionGoalRpcRequestSchema, LoroSessionTerminateRpcRequestSchema, @@ -1442,6 +1458,7 @@ export type LoroMachineRpcResult = | MachineBugReportResponse | SessionCancelResponse | LoroSessionLiveStatusRpcResponse + | SessionQueueSteerResponse | SessionSteerResponse | SessionGoalResponse | SessionTerminateResponse @@ -1470,6 +1487,7 @@ const toLegacyRpcErrorResponse = ( cancelContext?: { sessionId: string }, forkContext?: { sourceSessionId: string; targetSessionId: string }, editAndResendContext?: { sessionId: string; replacementUserTurnId: string }, + queueSteerContext?: { sessionId: string; queueItemId: string }, steerContext?: { sessionId: string; userTurnId: string }, goalContext?: { sessionId: string; action: SessionGoalAction }, previewContext?: { sessionId: string }, @@ -1646,6 +1664,17 @@ const toLegacyRpcErrorResponse = ( }; } + if (method === 'session/queue-steer') { + return { + type: 'session/queue-steer_response', + sessionId: (queueSteerContext?.sessionId ?? '') as SessionQueueSteerResponse['sessionId'], + queueItemId: queueSteerContext?.queueItemId ?? '', + accepted: false, + disposition: 'error', + error: `${error.code}: ${error.message}`, + }; + } + if (method === 'session/dispatch-turn') { return { type: 'session/dispatch-turn_response', @@ -1820,6 +1849,10 @@ const parseRpcSuccessResult = async ( const parsed = SessionSteerResponseSchema.safeParse(response.result); return parsed.success ? (parsed.data as SessionSteerResponse) : null; } + if (response.method === 'session/queue-steer') { + const parsed = SessionQueueSteerResponseSchema.safeParse(response.result); + return parsed.success ? (parsed.data as SessionQueueSteerResponse) : null; + } if (response.method === 'session/goal') { const parsed = SessionGoalResponseSchema.safeParse(response.result); return parsed.success ? (parsed.data as SessionGoalResponse) : null; @@ -1896,6 +1929,7 @@ export type LoroStreamsRpcPendingRegistration = { cancelContext?: { sessionId: string }; forkContext?: { sourceSessionId: string; targetSessionId: string }; editAndResendContext?: { sessionId: string; replacementUserTurnId: string }; + queueSteerContext?: { sessionId: string; queueItemId: string }; steerContext?: { sessionId: string; userTurnId: string }; goalContext?: { sessionId: string; action: SessionGoalAction }; previewContext?: { sessionId: string }; @@ -2280,6 +2314,7 @@ export class LoroStreamsRpcResponseDispatcher { finalPending.cancelContext, finalPending.forkContext, finalPending.editAndResendContext, + finalPending.queueSteerContext, finalPending.steerContext, finalPending.goalContext, finalPending.previewContext, @@ -2313,6 +2348,7 @@ export class LoroStreamsRpcResponseDispatcher { finalPending.cancelContext, finalPending.forkContext, finalPending.editAndResendContext, + finalPending.queueSteerContext, finalPending.steerContext, finalPending.goalContext, finalPending.previewContext, @@ -2692,6 +2728,25 @@ export class LoroStreamsMachineRpcClient { })) as SessionSteerResponse | null; } + async requestSessionQueueSteer(options: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + requestedByUserId: string; + timeoutMs?: number; + }): Promise { + return (await this.sendRequest({ + method: 'session/queue-steer', + timeoutMs: options.timeoutMs ?? 5_000, + params: { + sessionId: options.sessionId, + expectedTurnId: options.expectedTurnId, + queueItemId: options.queueItemId, + requestedByUserId: options.requestedByUserId, + }, + })) as SessionQueueSteerResponse | null; + } + async requestSessionGoal(options: { sessionId: string; action: SessionGoalAction; @@ -3169,6 +3224,16 @@ export class LoroStreamsMachineRpcClient { sessionId: string; }; } + | { + method: 'session/queue-steer'; + timeoutMs: number; + params: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + requestedByUserId: string; + }; + } | { method: 'session/steer'; timeoutMs: number; @@ -3403,6 +3468,10 @@ export class LoroStreamsMachineRpcClient { args.method === 'session/steer' ? { sessionId: args.params.sessionId, userTurnId: args.params.userTurnId } : undefined, + queueSteerContext: + args.method === 'session/queue-steer' + ? { sessionId: args.params.sessionId, queueItemId: args.params.queueItemId } + : undefined, goalContext: args.method === 'session/goal' ? { sessionId: args.params.sessionId, action: args.params.action } @@ -3512,6 +3581,9 @@ export class LoroStreamsMachineRpcClient { case 'session/live-status': request = { ...envelope, method: args.method, params: args.params }; break; + case 'session/queue-steer': + request = { ...envelope, method: args.method, params: args.params }; + break; case 'session/steer': request = { ...envelope, method: args.method, params: args.params }; break; @@ -3705,6 +3777,7 @@ export class LoroStreamsMachineRpcClient { pending.cancelContext, pending.forkContext, pending.editAndResendContext, + pending.queueSteerContext, pending.steerContext, pending.goalContext, pending.previewContext, diff --git a/packages/loro-streams-rpc/tests/loro-streams-rpc.test.ts b/packages/loro-streams-rpc/tests/loro-streams-rpc.test.ts index 204a39ad1..ab49ede0c 100644 --- a/packages/loro-streams-rpc/tests/loro-streams-rpc.test.ts +++ b/packages/loro-streams-rpc/tests/loro-streams-rpc.test.ts @@ -99,6 +99,30 @@ describe('session steer RPC schema', () => { }); }); +describe('queued message steer RPC schema', () => { + it('carries the exact queue and active-turn identities', () => { + const result = LoroStreamsRpcRequestSchema.safeParse({ + jsonrpc: '2.0', + id: 'request-queue-steer-1', + method: 'session/queue-steer', + rpcVersion: '1', + workspaceId: 'workspace-1', + machineId: 'machine-1', + replyTo: 'workspace-1:rpc:res', + sentAt: 1, + expiresAt: 2, + params: { + sessionId: 'session-1', + expectedTurnId: 'assistant:user-1', + queueItemId: 'queue-C', + requestedByUserId: 'user-1', + }, + }); + + expect(result.success).toBe(true); + }); +}); + describe('session preparation RPC schema', () => { const baseRequest = { jsonrpc: '2.0', diff --git a/packages/shared/src/local-machine-rpc.ts b/packages/shared/src/local-machine-rpc.ts index ef5dcbbf1..fbdab4918 100644 --- a/packages/shared/src/local-machine-rpc.ts +++ b/packages/shared/src/local-machine-rpc.ts @@ -37,6 +37,7 @@ import { SessionPreviewEndpointAcquireResponseSchema, SessionPreviewEndpointReleaseResponseSchema, PreviewTargetSchema, + SessionQueueSteerResponseSchema, SessionSteerResponseSchema, SessionGoalResponseSchema, SessionTerminateResponseSchema, @@ -193,6 +194,17 @@ export const LocalMachineRpcRequestSchema = z.discriminatedUnion('method', [ method: z.literal('session/prepare-cancel'), params: SessionPreparationCancelSpecSchema, }).strict(), + BaseLocalMachineRpcRequestSchema.extend({ + method: z.literal('session/queue-steer'), + params: z + .object({ + sessionId: SessionIdSchema, + expectedTurnId: z.string().trim().min(1), + queueItemId: z.string().trim().min(1), + requestedByUserId: z.string().trim().min(1), + }) + .strict(), + }).strict(), BaseLocalMachineRpcRequestSchema.extend({ method: z.literal('session/steer'), params: z @@ -271,6 +283,7 @@ export const LocalMachineRpcResultSchema = z.union([ SessionPrepareCancelResponseSchema, SessionPreviewEndpointAcquireResponseSchema, SessionPreviewEndpointReleaseResponseSchema, + SessionQueueSteerResponseSchema, SessionSteerResponseSchema, SessionGoalResponseSchema, SessionTerminateResponseSchema, diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 8c3cd04b2..f5c0998e9 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -681,6 +681,26 @@ export const SessionSteerResponseSchema = z }) .strict(); +export const SessionQueueSteerResponseSchema = z + .object({ + type: z.literal('session/queue-steer_response'), + sessionId: SessionIdSchema, + queueItemId: z.string().trim().min(1), + userTurnId: z.string().trim().min(1).optional(), + accepted: z.boolean(), + disposition: z.enum([ + 'accepted', + 'queue-item-missing', + 'invalid-queue-item', + 'no-active-turn', + 'stale-turn', + 'busy', + 'error', + ]), + error: z.string().optional(), + }) + .strict(); + export const SessionGoalResponseSchema = z .object({ type: z.literal('session/goal_response'), @@ -3398,6 +3418,7 @@ import type { ServerToMachine, SessionCancelRequest, SessionCancelResponse, + SessionQueueSteerResponse, SessionSteerRequest, SessionSteerResponse, SessionChatAck, @@ -3466,6 +3487,7 @@ export type { SessionChatAck as SessionChatAckValidated, SessionCancelRequest as SessionCancelRequestValidated, SessionCancelResponse as SessionCancelResponseValidated, + SessionQueueSteerResponse as SessionQueueSteerResponseValidated, SessionSteerRequest as SessionSteerRequestValidated, SessionSteerResponse as SessionSteerResponseValidated, PermissionRequestMessage as PermissionRequestMessageValidated, diff --git a/packages/shared/src/message.ts b/packages/shared/src/message.ts index 46d5dc6ce..6dacc28c6 100644 --- a/packages/shared/src/message.ts +++ b/packages/shared/src/message.ts @@ -166,6 +166,23 @@ export interface SessionSteerResponse { error?: string; } +export interface SessionQueueSteerResponse { + type: 'session/queue-steer_response'; + sessionId: SessionId; + queueItemId: string; + userTurnId?: string; + accepted: boolean; + disposition: + | 'accepted' + | 'queue-item-missing' + | 'invalid-queue-item' + | 'no-active-turn' + | 'stale-turn' + | 'busy' + | 'error'; + error?: string; +} + /** * Answer to a goal control request. * diff --git a/packages/shared/tests/local-machine-rpc.test.ts b/packages/shared/tests/local-machine-rpc.test.ts index c3c8b7f93..56dd08fc9 100644 --- a/packages/shared/tests/local-machine-rpc.test.ts +++ b/packages/shared/tests/local-machine-rpc.test.ts @@ -41,6 +41,15 @@ describe('local Machine RPC', () => { inputConfig: { prompt: 'guide' }, }, }, + { + method: 'session/queue-steer', + params: { + sessionId: 'session-1', + expectedTurnId: 'assistant:turn-0', + queueItemId: 'queue-C', + requestedByUserId: 'user-1', + }, + }, { method: 'session/prepare', params: { diff --git a/packages/shared/tests/message-schemas.test.ts b/packages/shared/tests/message-schemas.test.ts index 3d695f0a8..d91af6f6e 100644 --- a/packages/shared/tests/message-schemas.test.ts +++ b/packages/shared/tests/message-schemas.test.ts @@ -23,6 +23,7 @@ import { SessionCreateRequestSchema, SessionSteerRequestSchema, SessionSteerResponseSchema, + SessionQueueSteerResponseSchema, SessionImageUploadResponseSchema, SessionIdSchema, SessionImagePayloadSchema, @@ -211,6 +212,21 @@ describe('message-schemas session steer', () => { }); }); +describe('message-schemas queued message steer', () => { + it('accepts an exact queue identity acknowledgement', () => { + expect( + SessionQueueSteerResponseSchema.safeParse({ + type: 'session/queue-steer_response', + sessionId: 'session-1', + queueItemId: 'queue-C', + userTurnId: 'user-C', + accepted: true, + disposition: 'accepted', + }).success + ).toBe(true); + }); +}); + describe('message-schemas image upload response', () => { it('rejects empty uploaded image lists', () => { const result = SessionImageUploadResponseSchema.safeParse({ diff --git a/specs/message-queue-interactions.md b/specs/message-queue-interactions.md index 35f1952d3..d4cb6181f 100644 --- a/specs/message-queue-interactions.md +++ b/specs/message-queue-interactions.md @@ -15,13 +15,20 @@ the queue by hand. ## Contract - `Mod+Shift+Enter` sends the current composer draft with the opposite of the stored - Queue/Steer preference. This is a one-shot submission intent: it does not mutate the - preference, and it still obeys the ordinary availability, live-activity, and unfinished- - transcript safeguards. + Queue/Steer preference. The command passes `queueBehavior: "inverse"` directly to that + submission; ordinary Enter passes no override. This one-shot intent does not mutate the + preference and still obeys the ordinary availability, live-activity, and unfinished- + transcript safeguards. Composer focus, content, and send readiness are command-level + availability rules so user-rebound shortcuts retain them. - While steering is available for the active turn, every queued message exposes Steer. - Selecting a later item targets that exact item. Native acknowledged steering removes - and applies the selected item directly; the compatibility path first moves it to the - queue head and interrupts only after the reorder succeeds. + Selecting an item sends its durable queue identity and the expected active turn to the + owning daemon. The daemon revalidates both identities, atomically consumes only that row + into the next durable user turn, and then stops the expected turn. Queue order is never + rewritten as part of Steer: selecting C from `[A, B, C]` produces the active turn C and + leaves `[A, B]`. +- A stale Steer selection is a failed no-op. If the selected queue identity is missing, or + the expected turn no longer owns execution, the daemon must not stop any turn. The + renderer waits for this acknowledgement and never removes or materializes the row itself. - The number and non-editing message body form the drag target for queue reordering. Steer, edit, and remove remain separate controls and must not begin a drag. - Editing keeps its existing keyboard and focus behavior and disables reordering for @@ -40,7 +47,8 @@ physical-device coverage remains separate from component tests. - `packages/components/src/components/sessions/session-message-submit-route.ts` - `packages/components/src/components/sessions/session-chat-input-area.tsx` - `packages/components/src/components/sessions/message-queue/` -- `packages/components/tests/{session-message-submit-route,queued-message-steer,message-queue-row-editing}.test.*` +- `packages/components/tests/{session-message-submit-route,session-chat-input-submission,message-queue-row-editing}.test.*` +- `apps/cli/{tests/session-execution-service.test.ts,src/lib/loro/doc-user-turn.test.ts}` - [Decision record](../.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md) This is a draft for human review. Implementation and passing tests do not approve it. diff --git a/specs/message-queue-interactions.zh.md b/specs/message-queue-interactions.zh.md index d9277d56b..74f03e1e2 100644 --- a/specs/message-queue-interactions.zh.md +++ b/specs/message-queue-interactions.zh.md @@ -13,9 +13,15 @@ Translation: current ## 契约 - `Mod+Shift+Enter` 以已保存“排队/引导”偏好的相反行为发送当前草稿。这是一次性的提交 - 意图,不修改设置,并继续遵守普通的可用性、实时活动和未完成消息记录保护。 + 意图:命令直接为本次提交传入 `queueBehavior: "inverse"`,普通 Enter 不传覆盖项。它不 + 修改设置,并继续遵守普通的可用性、实时活动和未完成消息记录保护。输入框聚焦、有内容且 + 可以发送属于命令本身的可用条件,因此用户重绑快捷键后仍受同一限制。 - 当前 turn 可接受引导时,每条排队消息都提供“引导”操作。选择后续项必须以该项为目标。 - 原生带确认的 steer 直接移除并应用所选项;兼容路径先把它移到队首,只有重排成功后才中断。 + 客户端把该项的持久 ID 和预期活动 turn 一并发给所属 daemon;daemon 重新确认两者,在一次 + Session Doc 更新中只消费该项为下一个用户 turn,再停止预期 turn。“引导”绝不修改队列顺序: + 从 `[A, B, C]` 选择 C 后,正在执行的是 C,队列剩下 `[A, B]`。 +- 过期的“引导”选择必须是失败且无副作用的操作。所选 ID 已不存在,或预期 turn 已不再拥有 + 执行权时,daemon 不得停止任何 turn。Renderer 等待 daemon 确认,不自行移除队列项或写历史。 - 序号和非编辑状态的消息正文共同组成队列重排拖动区域。“引导”、“编辑”和“移除”是独立 控件,不能触发拖动。 - 编辑状态保留已有键盘与焦点行为,并在编辑结束前禁用该行重排。 @@ -31,7 +37,8 @@ Translation: current - `packages/components/src/components/sessions/session-message-submit-route.ts` - `packages/components/src/components/sessions/session-chat-input-area.tsx` - `packages/components/src/components/sessions/message-queue/` -- `packages/components/tests/{session-message-submit-route,queued-message-steer,message-queue-row-editing}.test.*` +- `packages/components/tests/{session-message-submit-route,session-chat-input-submission,message-queue-row-editing}.test.*` +- `apps/cli/{tests/session-execution-service.test.ts,src/lib/loro/doc-user-turn.test.ts}` - [决策记录](../.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md) 这是供人工审阅的草稿;实现和测试通过不代表 Spec 已获批准。 From 10bc5cd6f0d85352a8245cacbb718f64c1cfc00c Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:32:16 +0800 Subject: [PATCH 03/15] fix: negotiate queued message steering Model: gpt-5 --- .agents/docs/rpc-loro-streams-rpc.md | 9 +- .../2026-09-13-queue-steer-controls.md | 49 +++-- .../2026-09-13-queue-steer-controls.zh.md | 35 ++-- apps/cli/src/lib/loro/doc-user-turn.test.ts | 37 ++++ apps/cli/src/lib/loro/doc.ts | 28 ++- apps/cli/src/session/AGENTS.md | 3 +- apps/cli/src/session/README.md | 4 +- apps/cli/src/session/queued-message-turn.ts | 4 +- .../src/session/session-execution-service.ts | 95 +++++++++- ...ssage-handler-machine-registration.test.ts | 1 + .../tests/session-execution-service.test.ts | 175 ++++++++++++++++-- locales/en.json | 2 + locales/zh_CN.json | 2 + .../sessions/message-queue/AGENTS.md | 11 +- .../sessions/message-queue/index.ts | 5 + .../message-queue/message-queue-display.tsx | 6 + .../message-queue/message-queue-row.tsx | 24 ++- .../queued-message-steer-compat.ts | 26 +++ .../sessions/session-chat-interface.tsx | 156 +++++++++++++++- .../providers/workspace-machine-rpc-facade.ts | 12 ++ .../tests/message-queue-row-editing.test.tsx | 37 ++++ .../tests/queued-message-steer.test.ts | 49 +++++ .../workspace-machine-rpc-facade.test.ts | 25 +++ .../src/machine-protocol-capabilities.ts | 14 ++ packages/shared/src/message-schemas.ts | 2 + packages/shared/src/message.ts | 2 + .../machine-protocol-capabilities.test.ts | 15 ++ specs/message-queue-interactions.md | 34 +++- specs/message-queue-interactions.zh.md | 27 ++- 29 files changed, 795 insertions(+), 94 deletions(-) create mode 100644 packages/components/src/components/sessions/message-queue/queued-message-steer-compat.ts create mode 100644 packages/components/tests/queued-message-steer.test.ts diff --git a/.agents/docs/rpc-loro-streams-rpc.md b/.agents/docs/rpc-loro-streams-rpc.md index cda7c3eed..e4ee0e8ea 100644 --- a/.agents/docs/rpc-loro-streams-rpc.md +++ b/.agents/docs/rpc-loro-streams-rpc.md @@ -20,10 +20,11 @@ been removed. handlers. - `README.md` — package smoke-test notes. -`session/queue-steer` is an identity-based control operation, not queue reordering. -Its request names both the expected active turn and the exact queued item. The CLI -consumes that item before cancelling the expected turn; a missing item or stale turn -is rejected without stopping the current agent. +`session/queue-steer` is an identity-based control operation, not queue reordering. Its +request names both the expected active turn and exact queued item, and callers require the +negotiated `queueItemSteer` protocol capability. The CLI consumes the item only when its +editing lease is inactive, then preserves native ACP Steer when acknowledged or cancels the +expected turn for ordinary follow-up dispatch. Missing, editing, and stale targets do neither. ## Remote lifecycle acknowledgements diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md index 7da6fc93a..2853ef728 100644 --- a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md @@ -9,10 +9,11 @@ Translation: current Queue and Steer previously required changing a persistent preference, later queued items hid Steer, and reordering started only from the small leading handle. The adopted -interaction adds a one-shot inverse submission command, exposes Steer on every row, and -uses the row's message content as its drag target. Queue order and immediate Steer remain -independent: the daemon consumes a selected queue identity directly, and a missing identity -leaves the active turn untouched. +interaction adds a one-shot inverse submission command, exposes Steer on every row when the +daemon can identify that row safely, and uses the row's message content as its drag target. +Queue order and immediate Steer remain independent. Exact-item steering is version-negotiated, +preserves native ACP steering, respects editing leases, and never stops the active turn when +the selected identity is missing. ## Decision @@ -21,11 +22,21 @@ leaves the active turn untouched. `sendMessage({ queueBehavior: "inverse" })`; ordinary submission passes no option. The command-level predicate owns composer focus, content, and send readiness so user binding overrides cannot remove those rules. The routing resolver reverses only this submission. -- Show Steer for every row whenever the session-level action is available. The renderer sends - the queue `$cid` and expected active turn through `session/queue-steer`, then waits for the - daemon result without changing queue or history locally. Under its per-session mutation and - history-rewrite leases, the daemon revalidates the turn and consumes that exact row into - history in one Session Doc mutation before requesting exact-turn cancellation. +- Advertise `queueItemSteer` in `MachineMeta.protocolCapabilities`. The renderer calls + `session/queue-steer` only when that version is present; missing means unsupported. The + request names the queue `$cid` and expected active turn, and the renderer waits for the + result without changing queue or history locally. +- Let the daemon choose the execution mechanism after validating both identities and the + target's editing lease. With acknowledged native ACP Steer, it atomically consumes the row + as `pending_apply` and enters the existing `steerPrompt` handoff. Without native Steer, it + consumes the row as an ordinary pending turn and cancels only the expected active turn. +- Retain mixed-version behavior without reintroducing reorder-then-cancel. An older daemon + with an authoritative acknowledged-Steer capability uses the legacy native path. Other + older daemons retain only the established queue-head interrupt; later-row Steer controls + are disabled with an upgrade explanation. +- Keep a bounded daemon receipt for each completed exact operation key. A response-loss retry + returns the same result instead of consuming or cancelling twice. A cancellation failure + after consumption leaves one durable follow-up and is also returned idempotently. - Make the leading number and message body a single pointer and keyboard drag activator. Keep action buttons outside it, and disable it while the row editor owns interaction. @@ -34,20 +45,24 @@ leaves the active turn untouched. Keeping Steer on the first row would require users to perform an unrelated reorder first. Reorder-then-cancel was rejected because reorder can resolve after a concurrent peer deleted the selected row, causing Stop to target the current turn without any message to promote. -Renderer-side history materialization was rejected because the renderer cannot atomically -validate daemon turn ownership and queue identity. Making the complete row draggable was also -rejected because Steer, edit, and remove would become accidental drag starters. +Renderer-side history materialization remains only for old-daemon native compatibility; it +cannot provide exact-item atomicity. Removing native steering was rejected because +`steerPrompt` injects into the current prompt, while cancel-and-dispatch starts a new turn. +Making the complete row draggable was also rejected because Steer, edit, and remove would +become accidental drag starters. ## Verification and limits - Routing tests cover Queue → Steer and Steer → Queue inversion while a prompt is live. - Command tests cover the default binding, explicit submission option, and command-level composer-focus rule. -- Queue component tests cover Steer on a later row and verify that message content, but - not action buttons, belongs to the drag activator. -- CLI service and Session Doc tests cover consuming C from `[A, B, C]` as `[A, B]`, exact-turn - cancellation after consumption, and the missing-C failure path that never calls Stop. -- Machine RPC schema tests cover both required identities. +- Queue component tests cover later-row Steer, old-daemon head-only disabling, authoritative + legacy native selection, and the drag activator boundary. +- CLI service and Session Doc tests cover exact C consumption, native `steerPrompt`, exact + cancellation, missing and active-edit rejection, cancellation failure, and response-loss + retries. +- Machine RPC and protocol-capability tests cover both required identities and mixed-version + negotiation. - Component tests use synthetic pointer state. Physical touch dragging and a full provider-backed steer run were not exercised. diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md index 554636235..a86aff399 100644 --- a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md @@ -8,9 +8,9 @@ Translation: current ## 摘要 此前若要在“排队”和“引导”间切换,必须修改持久设置;后续队列项不显示“引导”,重排也只能 -从狭小的左侧把手开始。本次采用一次性反转提交命令、让每行都显示“引导”,并把消息内容区 -作为拖动区域。队列顺序与立即“引导”保持独立:daemon 直接消费所选队列 ID;如果该 ID 已 -不存在,则当前 turn 不受影响。 +从狭小的左侧把手开始。本次采用一次性反转提交命令、在 daemon 能安全识别目标行时让每行 +显示“引导”,并把消息内容区作为拖动区域。队列顺序与立即“引导”保持独立。精确队列项引导 +必须版本协商、保留 native ACP Steer、遵守 editing lease,并在目标 ID 缺失时保持当前 turn 不变。 ## 决策 @@ -18,10 +18,17 @@ Translation: current `Mod+Shift+Enter` 触发。输入框直接调用 `sendMessage({ queueBehavior: "inverse" })`,普通 提交不传选项。输入框聚焦、有内容且可以发送是命令级条件,因此用户覆盖 binding 后也不会 丢失;路由器只反转本次有效偏好。 -- 会话允许引导时,每一行都显示“引导”。Renderer 通过 `session/queue-steer` 发送队列 `$cid` - 和预期活动 turn,等待 daemon 返回,且不在本地修改队列或历史。Daemon 在每会话 mutation - 与历史 rewrite lease 下重新确认 turn,并在一次 Session Doc 更新中把该行消费进历史,再请求 - 精确取消该 turn。 +- 在 `MachineMeta.protocolCapabilities` 声明 `queueItemSteer`。只有该版本存在时,Renderer + 才调用 `session/queue-steer`;缺失即表示不支持。请求携带队列 `$cid` 与预期活动 turn, + Renderer 等待结果,不在本地修改队列或历史。 +- Daemon 确认两个 ID 及目标 editing lease 后决定执行机制。支持 acknowledged native ACP + Steer 时,原子消费目标行为 `pending_apply`,并进入既有 `steerPrompt` handoff;否则消费为 + 普通 pending turn,再只取消预期活动 turn。 +- 混合版本兼容不得恢复“先重排后取消”。旧 daemon 若 authoritative capability 声明支持 + acknowledged Steer,则使用旧 native 路径;其他旧 daemon 只保留既有队首 interrupt,后续行 + “引导”禁用并提示升级。 +- Daemon 为已完成的精确操作 key 保留有界 receipt。响应丢失后的重试返回相同结果,不再次 + 消费或取消。消费后取消失败也只留下一个持久 follow-up,并以幂等方式返回。 - 左侧序号和消息正文合并为一个支持鼠标及键盘的拖动区域。操作按钮保持在区域外;编辑器 接管交互时禁用该行拖动。 @@ -29,17 +36,19 @@ Translation: current 只允许队首“引导”会迫使用户先做一次无关重排。没有采用“重排后取消”:并发客户端删除所选 行时,重排仍可能 resolve,进而在没有可提升消息的情况下错误 Stop 当前 turn。也没有让 -Renderer 先写历史,因为它无法原子确认 daemon 的 turn 所有权和队列 ID。没有让整行都可拖动, -因为那会使“引导”、“编辑”和“移除”成为意外的拖动起点。 +Renderer 写历史仅保留给旧 daemon 的 native 兼容路径;它无法提供精确队列项原子性。没有删除 +native steer,因为 `steerPrompt` 注入当前 prompt,而 cancel-and-dispatch 会开启新 turn,两者 +语义不同。没有让整行都可拖动,因为那会使“引导”、“编辑”和“移除”成为意外拖动起点。 ## 验证与边界 - 路由测试覆盖 prompt 活动时“排队 → 引导”和“引导 → 排队”的反转。 - 命令测试覆盖默认 binding、显式提交选项和命令级输入框聚焦规则。 -- 队列组件测试覆盖对后续项执行“引导”,并验证消息内容属于拖动区域而操作按钮不属于。 -- CLI service 与 Session Doc 测试覆盖从 `[A, B, C]` 消费 C 后保留 `[A, B]`、消费后精确取消 - turn,以及 C 已缺失时绝不调用 Stop。 -- Machine RPC schema 测试覆盖两个必需 ID。 +- 队列组件测试覆盖后续行引导、旧 daemon 仅启用队首、authoritative legacy native 选择,以及 + 拖动区域边界。 +- CLI service 与 Session Doc 测试覆盖精确消费 C、native `steerPrompt`、精确取消、目标缺失、 + editing lease、取消失败及响应丢失重试。 +- Machine RPC 与 protocol capability 测试覆盖两个必需 ID 和混合版本协商。 - 组件测试使用合成指针状态;未验证物理触摸拖动和完整的 Provider-backed steer 流程。 ## 参考 diff --git a/apps/cli/src/lib/loro/doc-user-turn.test.ts b/apps/cli/src/lib/loro/doc-user-turn.test.ts index 805e3301e..5cd4bd288 100644 --- a/apps/cli/src/lib/loro/doc-user-turn.test.ts +++ b/apps/cli/src/lib/loro/doc-user-turn.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createSessionMirror, + getServerNow, type MessageQueueItem, type SessionHistoryInput, type SessionId, @@ -86,6 +87,42 @@ describe('SessionDocument.consumeMessageQueueItemAsUserTurn', () => { expect(state.mq.map((item) => item.$cid)).toEqual(['A']); expect(upsertDocMeta).not.toHaveBeenCalled(); }); + + it('does not consume an item while another client holds its editing lease', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc, state } = createSessionDocument({ upsertDocMeta }); + state.mq = [ + { + $cid: 'C', + task: 'task C', + timestamp: '2026-09-13T00:00:00.000Z', + isEditing: true, + editingStartedAt: getServerNow(), + }, + ]; + + await expect( + doc.consumeMessageQueueItemAsUserTurn('C', () => createUserTurn('user:C')) + ).resolves.toEqual({ type: 'editing' }); + expect(state.history).toEqual([]); + expect(state.mq.map((item) => item.$cid)).toEqual(['C']); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + + it('can reserve an exact item for native steer without publishing ordinary dispatch', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc, state } = createSessionDocument({ upsertDocMeta }); + state.mq = [{ $cid: 'C', task: 'task C', timestamp: '2026-09-13T00:00:00.000Z' }]; + + await expect( + doc.consumeMessageQueueItemAsUserTurn('C', () => createUserTurn('user:C'), { + publishDispatch: false, + }) + ).resolves.toMatchObject({ type: 'consumed', entry: { id: 'user:C' } }); + expect(state.history.map((entry) => entry.id)).toEqual(['user:C']); + expect(state.mq).toEqual([]); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); }); describe('SessionDocument.appendUserTurn', () => { diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index e3aae3632..805183caa 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -1742,6 +1742,12 @@ const acpRuntimeConfigEqual = ( */ const EDITING_LEASE_MS = 5 * 60 * 1000; +const hasActiveMessageQueueEditingLease = (item: MessageQueueItem): boolean => { + if (!item.isEditing) return false; + const startedAt = item.editingStartedAt ?? 0; + return getServerNow() - startedAt < EDITING_LEASE_MS; +}; + export class SessionDocument implements LoroDocument { mirror: import('@lody/shared').SessionMirror | null = null; handle: RepoDocHandle | null = null; @@ -2868,9 +2874,7 @@ export class SessionDocument implements LoroDocument SessionHistoryInput | null + buildEntry: (item: MessageQueueItem) => SessionHistoryInput | null, + options: { publishDispatch?: boolean } = {} ): Promise< | { type: 'consumed'; entry: SessionHistoryInput } | { type: 'missing' } + | { type: 'editing' } | { type: 'invalid' } > { if (!this.mirror) { @@ -2934,12 +2941,17 @@ export class SessionDocument implements LoroDocument { const queue = (prev.mq ?? []) as MessageQueueItem[]; const item = queue.find((candidate) => candidate.$cid === cid); if (!item) return prev; + if (hasActiveMessageQueueEditingLease(item)) { + outcome.value = { type: 'editing' }; + return prev; + } const entry = buildEntry(item); if (!entry) { @@ -2960,7 +2972,7 @@ export class SessionDocument implements LoroDocument); diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index 527054444..f5be54a59 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -25,7 +25,8 @@ Contract: specs/session-orchestration.md. ## Dispatch -- Queue promotion preserves frozen fields; Queue Steer consumes exact `$cid`; missing never stops. +- Queue promotion keeps frozen fields. Exact Steer respects edit leases/native support; + missing/stale targets never stop. - Absent session meta is "unknown", not foreign: hold the TTL-bounded RPC stash until meta lands; drop it only on a definitive verdict. - Subscribe to RPC offers BEFORE awaiting Doc Room join/sync and never dispatch from the RPC diff --git a/apps/cli/src/session/README.md b/apps/cli/src/session/README.md index e7318f1d0..0a65a7bd8 100644 --- a/apps/cli/src/session/README.md +++ b/apps/cli/src/session/README.md @@ -25,8 +25,8 @@ CLI/MCP orchestration contract is specs/session-orchestration.md. message-handler's `beginConversationTurn`, stored/disposed via `SessionTransientStore` turn state; it creates the assistant entry when it opens. - `session-execution-service.ts` — runs one turn end-to-end and owns exact-item queue - Steer serialization: validate the active turn and queued item, consume that item, - then cancel only the expected turn. It also owns ACP prompt, turn ids, + Steer serialization: validate the active turn, queued item, and editing lease, then + choose native `steerPrompt` or exact cancel-and-dispatch. It also owns ACP prompt, turn ids, lifecycle/error handling, GitHub/local project setup, and post-turn diffStats. - `acp-error-classification.ts` — JSON-RPC/transport error string matching for the above. - `session-manager.ts` / `session.ts` / `session-sandbox.ts` / `terminal-manager.ts` — diff --git a/apps/cli/src/session/queued-message-turn.ts b/apps/cli/src/session/queued-message-turn.ts index 37472c0d6..9e68ae7cf 100644 --- a/apps/cli/src/session/queued-message-turn.ts +++ b/apps/cli/src/session/queued-message-turn.ts @@ -25,7 +25,8 @@ const isConfigOptionValueRecord = ( /** Materialize one durable queue item as the user turn that will execute it. */ export function buildQueuedMessageUserTurn( queuedItem: MessageQueueItem, - meta: SessionMeta + meta: SessionMeta, + options: { status?: 'pending' | 'pending_apply' } = {} ): SessionHistoryInput | null { const inputBlocks = normalizeSessionInputBlocks( queuedItem.acpSessionConfig?.inputBlocks, @@ -53,6 +54,7 @@ export function buildQueuedMessageUserTurn( inputBlocks, timestamp: queuedItem.timestamp, inputConfig, + ...(options.status ? { status: options.status } : {}), }); if (!pendingEntry) return null; diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 7316d7659..a0e57a7e7 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -58,6 +58,7 @@ import { hasBuiltinRuntimeOverrideValues, getManagedBuiltinRuntimeByAgentType, getManagedBuiltinRuntimeByRuntimeName, + normalizeSessionTurnInputConfig, serializeCustomAcpLaunchSpec, } from '@lody/shared'; import type { ContentBlock } from '@agentclientprotocol/sdk'; @@ -724,6 +725,8 @@ type TurnAnalyticsState = { hasReplayPrompt: boolean; }; +const QUEUE_STEER_RECEIPT_LIMIT = 512; + export class SessionExecutionService { private readonly canceledTurnBySession = new Map(); private readonly currentTurnBySession = new Map(); @@ -738,6 +741,8 @@ export class SessionExecutionService { // application never race the boundary. No global concurrency cap (Infinity): // this is pure per-session serialization, matching the old hand-rolled lock. private readonly steerMutationQueue = new ConcurrentQueue(Number.POSITIVE_INFINITY); + /** Bounded receipts make a response-loss retry observationally idempotent. */ + private readonly queueSteerReceipts = new Map(); // Analytics-only state (spec §5b). Tracks per-turn timing + the last status // we reported so status_changed can carry from→to + dwell time. Never read by // product logic; kept here so capture stays side-effect-only. @@ -761,6 +766,20 @@ export class SessionExecutionService { this.acpAuthenticationManager = new AcpAuthenticationManager(deps.logger); } + private rememberQueueSteerReceipt( + operationKey: string, + response: SessionQueueSteerResponse + ): SessionQueueSteerResponse { + this.queueSteerReceipts.delete(operationKey); + this.queueSteerReceipts.set(operationKey, response); + while (this.queueSteerReceipts.size > QUEUE_STEER_RECEIPT_LIMIT) { + const oldest = this.queueSteerReceipts.keys().next().value; + if (oldest === undefined) break; + this.queueSteerReceipts.delete(oldest); + } + return response; + } + private createPromptHandoffRun(options: { turnId: string; promptPromise: Promise; @@ -1395,6 +1414,11 @@ export class SessionExecutionService { queueItemId: string; requestedByUserId: string; }): Promise { + const operationKey = JSON.stringify([ + options.sessionId, + options.expectedTurnId, + options.queueItemId, + ]); const respond = ( disposition: SessionQueueSteerResponse['disposition'], details?: { userTurnId?: string; error?: string } @@ -1409,10 +1433,16 @@ export class SessionExecutionService { }); return await this.steerMutationQueue.enqueue(options.sessionId, async () => { + const previousReceipt = this.queueSteerReceipts.get(operationKey); + if (previousReceipt) return previousReceipt; + const releaseConflict = this.tryAcquireSessionRewriteConflictLease(options.sessionId); if (!releaseConflict) { return respond('busy', { error: 'The session history is being replaced.' }); } + let queueItemConsumed = false; + const finishConsumed = (response: SessionQueueSteerResponse): SessionQueueSteerResponse => + queueItemConsumed ? this.rememberQueueSteerReceipt(operationKey, response) : response; try { const runtime = this.turnRuntimeBySession.get(options.sessionId); if (!runtime || !runtime.session || !runtime.promptInFlight || runtime.cancelRequested) { @@ -1440,19 +1470,68 @@ export class SessionExecutionService { return respond('stale-turn'); } + const agentClient = runtime.session.agentClient; + const useNativeSteer = Boolean( + agentClient && + runtime.session.acpSessionId && + agentClient.getAcknowledgedSteerCapability() + ); + // Queue order and immediate steering are separate mutations. Consume the // selected identity directly; A/B keep their relative order when C wins. + // Native steer owns dispatch publication through its handoff path. const consumed = await sessionDoc.consumeMessageQueueItemAsUserTurn( options.queueItemId, - (item) => buildQueuedMessageUserTurn(item, meta) + (item) => + buildQueuedMessageUserTurn(item, meta, { + status: useNativeSteer ? 'pending_apply' : 'pending', + }), + { publishDispatch: !useNativeSteer } ); if (consumed.type === 'missing') { return respond('queue-item-missing'); } + if (consumed.type === 'editing') { + return respond('queue-item-editing'); + } if (consumed.type === 'invalid') { return respond('invalid-queue-item'); } + queueItemConsumed = true; const entry = consumed.entry; + + if (useNativeSteer) { + const inputConfig = normalizeSessionTurnInputConfig(entry.inputConfig); + const userId = entry.userId?.trim(); + const timestamp = entry.timestamp?.trim(); + if (!inputConfig || !userId || !timestamp) { + return finishConsumed( + respond('invalid-queue-item', { + userTurnId: entry.id, + error: 'The queued message cannot be converted to a steer turn.', + }) + ); + } + const nativeResult = await this.steerSessionLocked({ + sessionId: options.sessionId, + expectedTurnId: options.expectedTurnId, + userTurnId: entry.id, + userId, + timestamp, + inputConfig, + }); + const rejectedDisposition = + nativeResult.disposition === 'applied' ? 'error' : nativeResult.disposition; + return finishConsumed( + nativeResult.applied + ? respond('accepted', { userTurnId: entry.id }) + : respond(rejectedDisposition, { + userTurnId: entry.id, + error: nativeResult.error, + }) + ); + } + const cancellation = await this.cancelSession({ type: 'session/cancel', sessionId: options.sessionId, @@ -1461,14 +1540,16 @@ export class SessionExecutionService { turnId: options.expectedTurnId, }); if (!cancellation.success) { - return respond('error', { - userTurnId: entry.id, - error: cancellation.error ?? 'The active turn could not be stopped.', - }); + return finishConsumed( + respond('error', { + userTurnId: entry.id, + error: cancellation.error ?? 'The active turn could not be stopped.', + }) + ); } - return respond('accepted', { userTurnId: entry.id }); + return finishConsumed(respond('accepted', { userTurnId: entry.id })); } catch (error) { - return respond('error', { error: formatErrorMessage(error) }); + return finishConsumed(respond('error', { error: formatErrorMessage(error) })); } finally { releaseConflict(); } diff --git a/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index 2933d59e6..90c428cfe 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -187,6 +187,7 @@ describe('MessageHandler machine registration', () => { localFileResources: 1, providerSetup: 1, acpProtocolAuthentication: 2, + queueItemSteer: 1, subagentCancellation: 1, }); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index ffde55895..a0696d035 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -338,20 +338,27 @@ describe('SessionExecutionService', () => { ).turnRuntimeBySession.set(sessionId, runtime); const cancel = vi.spyOn(service, 'cancelSession').mockResolvedValue({ success: true }); - await expect( - service.steerQueuedMessage({ - sessionId, - expectedTurnId: activeTurnId, - queueItemId: 'C', - requestedByUserId: 'owner-user', - }) - ).resolves.toMatchObject({ + const request = { + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + requestedByUserId: 'owner-user', + }; + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ accepted: true, disposition: 'accepted', queueItemId: 'C', userTurnId: 'user:C', }); + // A response-loss retry returns the receipt instead of consuming or + // cancelling a second time after the active turn has moved on. + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: true, + disposition: 'accepted', + userTurnId: 'user:C', + }); + expect(queue.map((item) => item.$cid)).toEqual(['A', 'B']); expect(history).toHaveLength(1); expect(history[0]).toMatchObject({ id: 'user:C', role: 'user' }); @@ -359,6 +366,8 @@ describe('SessionExecutionService', () => { expect(sessionDoc.consumeMessageQueueItemAsUserTurn.mock.invocationCallOrder[0]).toBeLessThan( cancel.mock.invocationCallOrder[0]! ); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); }); it('keeps the active turn running when the selected queue identity is gone', async () => { @@ -414,6 +423,114 @@ describe('SessionExecutionService', () => { ) ).toBe(runtime); }); + + it('keeps the active turn and queue row while the target editing lease is active', async () => { + const sessionId = 'session-queue-steer-editing' as SessionId; + const activeTurnId = 'assistant:active'; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + consumeMessageQueueItemAsUserTurn: vi.fn(async () => ({ type: 'editing' as const })), + }; + const service = new SessionExecutionService( + createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession'); + + await expect( + service.steerQueuedMessage({ + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + requestedByUserId: 'owner-user', + }) + ).resolves.toMatchObject({ accepted: false, disposition: 'queue-item-editing' }); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not consume or cancel again after consumption succeeded but cancellation failed', async () => { + const sessionId = 'session-queue-steer-cancel-failed' as SessionId; + const activeTurnId = 'assistant:active'; + const entry = { + id: 'user:C', + role: 'user' as const, + userId: 'owner-user', + timestamp: '2026-09-13T00:00:00.000Z', + items: [{ type: 'text' as const, text: 'task C' }], + status: 'pending' as const, + read: false, + inputConfig: { prompt: 'task C' }, + }; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ id: sessionId })), + consumeMessageQueueItemAsUserTurn: vi.fn(async () => ({ + type: 'consumed' as const, + entry, + })), + }; + const service = new SessionExecutionService( + createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi + .spyOn(service, 'cancelSession') + .mockResolvedValue({ success: false, error: 'cancel failed' }); + const request = { + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + requestedByUserId: 'owner-user', + }; + + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + userTurnId: 'user:C', + error: 'cancel failed', + }); + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + userTurnId: 'user:C', + }); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + it('advances one session owner through consecutive prompt handoffs', async () => { const steerPrompt = vi.fn(() => ({ completion: new Promise(() => {}), @@ -432,7 +549,30 @@ describe('SessionExecutionService', () => { steerPrompt, currentModel: undefined, }; + const queuedItem: MessageQueueItem = { + $cid: 'queue-user-2', + task: 'change direction', + userId: 'user-1', + userTurnId: 'user-2', + timestamp: '2026-07-11T00:00:00.000Z', + acpSessionConfig: { prompt: 'change direction' }, + }; const sessionDoc = { + getMetaState: vi.fn(async () => ({ + id: 'session-steer', + userId: 'user-1', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + consumeMessageQueueItemAsUserTurn: vi.fn( + async (cid: string, buildEntry: (item: MessageQueueItem) => SessionHistoryInput | null) => { + expect(cid).toBe(queuedItem.$cid); + const entry = buildEntry(queuedItem); + if (!entry) return { type: 'invalid' as const }; + return { type: 'consumed' as const, entry }; + } + ), updateHistory: vi.fn(async () => {}), }; const upsertDocMeta = vi.fn(async () => {}); @@ -491,15 +631,13 @@ describe('SessionExecutionService', () => { ).turnRuntimeBySession.set(sessionId, runtime); await expect( - service.steerSession({ + service.steerQueuedMessage({ sessionId, expectedTurnId: 'assistant:user-1', - userTurnId: 'user-2', - userId: 'user-1', - timestamp: '2026-07-11T00:00:00.000Z', - inputConfig: { prompt: 'change direction' }, + queueItemId: queuedItem.$cid, + requestedByUserId: 'user-1', }) - ).resolves.toMatchObject({ applied: true, disposition: 'applied' }); + ).resolves.toMatchObject({ accepted: true, disposition: 'accepted', userTurnId: 'user-2' }); expect(onTurnSettled).toHaveBeenCalledOnce(); expect(onTurnSettled).toHaveBeenCalledWith('handled'); @@ -518,16 +656,21 @@ describe('SessionExecutionService', () => { ); expect(runtime.turnId).toBe('assistant:user-2'); expect(runtime.userTurnId).toBe('user-2'); - expect(runtime.invocation).toEqual({ + expect(runtime.invocation).toMatchObject({ requesterUserId: 'user-1', sourceTurnId: 'user-2', inputConfig: { prompt: 'change direction' }, }); - expect(service.getActiveInvocationContext(sessionId)).toEqual({ + expect(service.getActiveInvocationContext(sessionId)).toMatchObject({ requesterUserId: 'user-1', sourceTurnId: 'user-2', inputConfig: { prompt: 'change direction' }, }); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn).toHaveBeenCalledWith( + queuedItem.$cid, + expect.any(Function), + { publishDispatch: false } + ); expect(initialPromptRun.successor?.turnId).toBe('assistant:user-2'); expect(runtime.activePromptRun.turnId).toBe('assistant:user-2'); diff --git a/locales/en.json b/locales/en.json index 3e45dd222..9e598c74b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1750,6 +1750,7 @@ "sessions.messageQueue.saveEdit": "Save changes (Enter)", "sessions.messageQueue.title": "Queued messages", "sessions.messageQueue.upNext": "Up next", + "sessions.messageQueue.updateAgentForLaterSteer": "Update the local agent to steer a later queued message", "sessions.messageStatus.deliverNow": "Deliver now", "sessions.messageStatus.deliverNowFailed": "Failed to deliver the message - please try again", "sessions.messageStatus.notDelivered": "Not delivered", @@ -1981,6 +1982,7 @@ "sessions.proposedPlanDecision.executing": "Implementing plan...", "sessions.queueEditError": "Failed to edit message", "sessions.queueError": "Failed to queue message", + "sessions.queueItemEditing": "Finish editing this queued message before steering it", "sessions.queueItemMissing": "This queued message is no longer available", "sessions.queueRemoveError": "Failed to remove message from queue", "sessions.queueReorderError": "Failed to reorder messages", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index ec4dbc36d..d2d3906bf 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1750,6 +1750,7 @@ "sessions.messageQueue.saveEdit": "保存修改(回车)", "sessions.messageQueue.title": "排队中的消息", "sessions.messageQueue.upNext": "接下来", + "sessions.messageQueue.updateAgentForLaterSteer": "更新本地 Agent 后才能引导后续排队消息", "sessions.messageStatus.deliverNow": "重新发送", "sessions.messageStatus.deliverNowFailed": "送达失败,请重试", "sessions.messageStatus.notDelivered": "未送达", @@ -1981,6 +1982,7 @@ "sessions.proposedPlanDecision.executing": "正在实施计划...", "sessions.queueEditError": "编辑消息失败", "sessions.queueError": "消息入队失败", + "sessions.queueItemEditing": "请先完成这条排队消息的编辑,再进行引导", "sessions.queueItemMissing": "这条排队消息已不存在", "sessions.queueRemoveError": "移除消息失败", "sessions.queueReorderError": "调整消息顺序失败", diff --git a/packages/components/src/components/sessions/message-queue/AGENTS.md b/packages/components/src/components/sessions/message-queue/AGENTS.md index d70efcb8f..2428e91f1 100644 --- a/packages/components/src/components/sessions/message-queue/AGENTS.md +++ b/packages/components/src/components/sessions/message-queue/AGENTS.md @@ -9,11 +9,12 @@ queued-turn list (`message-queue-display.tsx`, `message-queue-row.tsx`, `../session-message-submit-route.ts` and is described in [.agents/docs/sessions-live-status.md](../../../../../../.agents/docs/sessions-live-status.md). -Every queued row offers Steer while an active turn can accept it. Steer sends the -selected `$cid` and expected active turn to the owning daemon; it never reorders, -removes, or materializes a queue row in the renderer. A rejected or missing identity -must leave the active turn running. A row's number and message body are one drag -activator; its Steer, edit, and remove controls stay outside that activator. +Exact-item Steer requires the negotiated `queueItemSteer` daemon protocol; missing means +unsupported. The daemon chooses acknowledged native Steer or exact cancel-and-dispatch. +For older daemons, an authoritative `acknowledgedSteer` capability must retain the legacy +native path; otherwise only queue-head interrupt stays enabled. Never reorder a later row +to emulate Steer. A missing, stale, or actively edited exact target must leave the current +turn running. A row's number and message body are one drag activator; its actions stay out. The queue intentionally stays OUT of the composer info bar ([.agents/docs/sessions-info-bar.md](../../../../../../.agents/docs/sessions-info-bar.md)). diff --git a/packages/components/src/components/sessions/message-queue/index.ts b/packages/components/src/components/sessions/message-queue/index.ts index 078978312..bc994ea31 100644 --- a/packages/components/src/components/sessions/message-queue/index.ts +++ b/packages/components/src/components/sessions/message-queue/index.ts @@ -4,6 +4,11 @@ export { MessageQueueRow } from './message-queue-row'; export type { MessageQueueRowProps } from './message-queue-row'; export { QueuedImagePreview } from './queued-image-preview'; export type { QueuedImageBlock } from './queued-image-preview'; +export { + resolveQueuedMessageSteerRoute, + shouldUseLegacyNativeQueueSteer, + type QueuedMessageSteerRoute, +} from './queued-message-steer-compat'; export { useMessageQueueEditing, getEditableTaskText, diff --git a/packages/components/src/components/sessions/message-queue/message-queue-display.tsx b/packages/components/src/components/sessions/message-queue/message-queue-display.tsx index 00bffbe28..2e1255060 100644 --- a/packages/components/src/components/sessions/message-queue/message-queue-display.tsx +++ b/packages/components/src/components/sessions/message-queue/message-queue-display.tsx @@ -37,6 +37,8 @@ export type MessageQueueDisplayProps = { onEditSave: (item: MessageQueueItem, task: string) => void | Promise; onSteer: (item: MessageQueueItem) => void | Promise; showSteerAction?: boolean; + steerActionScope?: 'all' | 'head'; + steerDisabledReason?: string; className?: string; }; @@ -52,6 +54,8 @@ export function MessageQueueDisplay({ onEditSave, onSteer, showSteerAction = false, + steerActionScope = 'all', + steerDisabledReason, className, }: MessageQueueDisplayProps) { const { t } = useTranslation(); @@ -157,6 +161,8 @@ export function MessageQueueDisplay({ item={item} index={index} showSteerAction={showSteerAction} + steerDisabled={steerActionScope === 'head' && index > 0} + steerDisabledReason={steerDisabledReason} canReorder={canReorder} isEditing={isEditing} editValue={isEditing ? editing.editValue : ''} diff --git a/packages/components/src/components/sessions/message-queue/message-queue-row.tsx b/packages/components/src/components/sessions/message-queue/message-queue-row.tsx index 5bee9d6d8..7dc1b5fcf 100644 --- a/packages/components/src/components/sessions/message-queue/message-queue-row.tsx +++ b/packages/components/src/components/sessions/message-queue/message-queue-row.tsx @@ -18,6 +18,8 @@ export type MessageQueueRowProps = { item: MessageQueueItem; index: number; showSteerAction: boolean; + steerDisabled?: boolean; + steerDisabledReason?: string; canReorder: boolean; isEditing: boolean; editValue: string; @@ -284,7 +286,16 @@ function RowBody(props: MessageQueueRowProps & EditCommitProps) { function RowActions(props: MessageQueueRowProps) { const { t } = useTranslation(); - const { item, showSteerAction, isEditing, onStartEdit, onRemove, onSteer } = props; + const { + item, + showSteerAction, + steerDisabled, + steerDisabledReason, + isEditing, + onStartEdit, + onRemove, + onSteer, + } = props; // In edit mode the textarea owns the row: it carries its own confirm button, so we // render no row-level actions that would compete for the click mid-edit. @@ -301,6 +312,8 @@ function RowActions(props: MessageQueueRowProps) { 'sessions.messageQueue.guide', 'Steer the active response with this message' )} + disabled={steerDisabled} + disabledReason={steerDisabledReason} onClick={() => { void onSteer(item); }} @@ -326,21 +339,28 @@ function RowActions(props: MessageQueueRowProps) { function TextAction({ text, ariaLabel, + disabled, + disabledReason, onClick, }: { text: string; ariaLabel: string; + disabled?: boolean; + disabledReason?: string; onClick: () => void; }) { return (