From 5184b2f856909645a75ddefa5460e1cf175da163 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:00:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20run=20=E4=B8=AD=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=B4=BE=E5=8F=91=E9=98=9F=E5=88=97=E4=B8=8E=E6=92=A4?= =?UTF-8?q?=E9=94=80/=E6=94=B9=E5=90=91=E8=AF=AD=E4=B9=89(#1965)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- app/shared/src/chatview/i18n/resources.ts | 46 ++- .../src/AgentHubWorkbench.module.css | 238 ++++++++++++++- .../src/ComposerDispatchQueue.test.tsx | 194 ++++++++++++ app/workbench/src/ComposerDispatchQueue.tsx | 176 +++++++++++ app/workbench/src/ConversationHost.tsx | 265 ++++++++++------ .../src/__tests__/dispatchQueue.test.tsx | 203 +++++++++--- .../src/composer/pendingIntents.test.ts | 289 +++++++++++++++--- app/workbench/src/composer/pendingIntents.ts | 234 ++++++++++---- 8 files changed, 1389 insertions(+), 256 deletions(-) create mode 100644 app/workbench/src/ComposerDispatchQueue.test.tsx create mode 100644 app/workbench/src/ComposerDispatchQueue.tsx diff --git a/app/shared/src/chatview/i18n/resources.ts b/app/shared/src/chatview/i18n/resources.ts index 99e73b178..a4b167426 100644 --- a/app/shared/src/chatview/i18n/resources.ts +++ b/app/shared/src/chatview/i18n/resources.ts @@ -293,7 +293,6 @@ export const chatviewResources = { 'toast.sentWithoutDispatch': '消息已发送;未 @ Agent,不会触发任务。@ 提及 Agent 后再发送即可派单。', 'toast.dispatchRetryExhausted': '派单重试 {max} 次仍被拒绝,已放弃自动重试,请稍后手动重新触发该 Agent', 'toast.dispatchRetryFailed': '派单重试失败,请手动重新触发该 Agent', - 'toast.pendingDispatchBadge': '待发送 {count} 条', 'toast.actionRecorded': '操作已记录', 'toast.multiCopy': '已复制 {count} 项', 'toast.multiDelete': '已删除 {count} 项', @@ -476,6 +475,29 @@ export const chatviewResources = { 'messageSearch.footerJump': '跳转', 'composer.agentPicker': 'Agent', 'composer.targetPicker': '执行目标', + // ── #1965 visible dispatch queue (queue/steer semantics, UX F7) ── + // Copy contract: the queue only schedules the NEXT dispatch after the + // active run ends; undo cancels dispatch, never the persisted message. + 'composer.queue.title': '待派发队列', + 'composer.queue.summaryRunning': '{{count}} 条排队中,当前任务结束后按序派发', + 'composer.queue.summaryIdle': '{{count}} 条排队中,即将按序派发', + 'composer.queue.clearAll': '清空队列', + 'composer.queue.notice': '队列只安排下一轮派单的顺序与目标,不会打断正在运行的任务;撤销只取消派单,已发送的消息保留在聊天记录中。', + 'composer.queue.item': '第 {{index}} 条:{{text}}', + 'composer.queue.retarget': '更改派单目标:{{text}}', + 'composer.queue.statusQueued': '排队中', + 'composer.queue.statusDispatching': '派发中', + 'composer.queue.statusRetrying': '第 {{attempt}} 次重试中', + 'composer.queue.statusRetryExhausted': '重试 {{attempt}} 次仍被拒绝', + 'composer.queue.statusFailed': '派发失败', + 'composer.queue.retry': '重试', + 'composer.queue.moveToFront': '置顶:{{text}}', + 'composer.queue.moveUp': '上移:{{text}}', + 'composer.queue.moveDown': '下移:{{text}}', + 'composer.queue.undo': '撤销派单:{{text}}', + 'composer.queue.undoAction': '撤销', + 'composer.queue.undoConfirmed': '已撤销派单;消息仍保留在聊天记录中', + 'composer.queue.clearConfirmed': '已清空待派单队列;消息仍保留在聊天记录中', 'profile.agentConfig': 'Agent 配置', 'profile.role': '职责', 'profile.engine': '引擎', @@ -1053,7 +1075,6 @@ export const chatviewResources = { 'toast.sentWithoutDispatch': 'Message sent; no agent was @mentioned, so no task was triggered. @ an agent to dispatch.', 'toast.dispatchRetryExhausted': 'Dispatch retried {max} times and was still rejected; auto-retry stopped. Re-trigger the agent manually later.', 'toast.dispatchRetryFailed': 'Dispatch retry failed; re-trigger the agent manually.', - 'toast.pendingDispatchBadge': '{count} pending', 'toast.actionRecorded': 'Action recorded', 'toast.multiCopy': '{count} item(s) copied', 'toast.multiDelete': '{count} item(s) deleted', @@ -1236,6 +1257,27 @@ export const chatviewResources = { 'messageSearch.footerJump': 'jump', 'composer.agentPicker': 'Agent', 'composer.targetPicker': 'Execution target', + // ── #1965 visible dispatch queue (queue/steer semantics, UX F7) ── + 'composer.queue.title': 'Dispatch queue', + 'composer.queue.summaryRunning': '{{count}} queued; dispatched in order once the current run ends', + 'composer.queue.summaryIdle': '{{count}} queued; dispatching in order', + 'composer.queue.clearAll': 'Clear queue', + 'composer.queue.notice': 'The queue only sets the order and target of the next dispatch; it never interrupts the running task. Undo cancels dispatch only — the sent message stays in the transcript.', + 'composer.queue.item': 'Item {{index}}: {{text}}', + 'composer.queue.retarget': 'Change dispatch target: {{text}}', + 'composer.queue.statusQueued': 'Queued', + 'composer.queue.statusDispatching': 'Dispatching', + 'composer.queue.statusRetrying': 'Retry {{attempt}} in progress', + 'composer.queue.statusRetryExhausted': 'Rejected after {{attempt}} retries', + 'composer.queue.statusFailed': 'Dispatch failed', + 'composer.queue.retry': 'Retry', + 'composer.queue.moveToFront': 'Move to front: {{text}}', + 'composer.queue.moveUp': 'Move up: {{text}}', + 'composer.queue.moveDown': 'Move down: {{text}}', + 'composer.queue.undo': 'Cancel dispatch: {{text}}', + 'composer.queue.undoAction': 'Undo', + 'composer.queue.undoConfirmed': 'Dispatch cancelled; the message stays in the transcript', + 'composer.queue.clearConfirmed': 'Dispatch queue cleared; messages stay in the transcript', 'profile.agentConfig': 'Agent configuration', 'profile.role': 'Role', 'profile.engine': 'Engine', diff --git a/app/workbench/src/AgentHubWorkbench.module.css b/app/workbench/src/AgentHubWorkbench.module.css index d1f731a52..6b93d203f 100644 --- a/app/workbench/src/AgentHubWorkbench.module.css +++ b/app/workbench/src/AgentHubWorkbench.module.css @@ -1300,19 +1300,235 @@ pointer-events: none; } -/* ── Pending dispatch queue badge (CF22) ── */ -.pendingIntentBadge { - align-self: center; - margin-top: var(--sp-xxs); - padding: 2px 12px; +/* ── Visible dispatch queue (#1965, UX F7) ── + Rows are dispatch intents for already-persisted Hub messages. Controls + only affect the NEXT dispatch (order/target/retry), never the run that + is currently executing. */ +.composerQueue { + position: relative; + z-index: var(--z-base); + width: min(100%, 820px); + margin: var(--sp-xxs) auto 0; + padding: var(--sp-10) var(--sp-14); + border: 1px solid var(--glass-border-strong, var(--td-line)); + border-radius: var(--r-xl); + background: var(--glass-panel, color-mix(in srgb, var(--td-surface) 86%, var(--td-canvas))); + backdrop-filter: var(--glass-backdrop-filter); + -webkit-backdrop-filter: var(--glass-backdrop-filter); + box-shadow: var(--glass-elev-1); + pointer-events: auto; +} + +.composerQueueHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-10); +} + +.composerQueueHeading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--sp-xxs) var(--sp-10); + min-width: 0; +} + +.composerQueueTitle { + color: var(--td-ink); + font: 600 12px/20px var(--td-font); +} + +.composerQueueSummary { + color: var(--td-ink-muted); + font: var(--caption); +} + +.composerQueueClear { + flex: none; + padding: 2px 10px; border: 1px solid var(--glass-border, var(--td-line)); border-radius: 999px; + background: transparent; + color: var(--td-ink-muted); + font: 600 11px/18px var(--td-font); + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease); +} + +.composerQueueClear:disabled { + opacity: 0.5; + cursor: default; +} + +.composerQueueClear:hover:not(:disabled) { background: var(--glass-bg-light, var(--td-surface-2)); - color: var(--text-secondary, var(--fg-muted)); - font-size: 12px; - line-height: 20px; - pointer-events: none; - user-select: none; + color: var(--td-ink); +} + +.composerQueueNotice { + margin: 2px 0 var(--sp-xxs); + color: var(--td-ink-subtle); + font: var(--caption); +} + +.composerQueueList { + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + margin: 0; + padding: 0; + list-style: none; +} + +.composerQueueItem { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-xxs) var(--sp-10); + padding: var(--sp-xxs) var(--sp-10); + border: 1px solid var(--glass-border, var(--td-line)); + border-radius: var(--r-md); + background: var(--glass-bg-light, var(--td-surface-2)); +} + +.composerQueueItem[data-status='dispatching'] { + border-color: color-mix(in srgb, var(--td-accent) 45%, var(--td-line)); +} + +.composerQueueItem[data-status='retrying'] { + border-color: color-mix(in srgb, var(--td-warning) 45%, var(--td-line)); +} + +.composerQueueItem[data-status='failed'] { + border-color: color-mix(in srgb, var(--td-danger) 45%, var(--td-line)); +} + +.composerQueueIndex { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--td-surface-3); + color: var(--td-ink-muted); + font: 600 11px/1 var(--td-font); +} + +.composerQueueBody { + display: flex; + flex: 1 1 240px; + flex-direction: column; + min-width: 0; +} + +.composerQueuePreview { + color: var(--td-ink); + font: var(--caption); + overflow-wrap: anywhere; +} + +.composerQueueMeta { + display: flex; + flex-wrap: wrap; + gap: 0 var(--sp-10); + color: var(--td-ink-subtle); + font: 12px/18px var(--td-font); +} + +.composerQueueAgent { + color: var(--td-ink-muted); + font-weight: 600; +} + +.composerQueueState { + color: var(--td-ink-subtle); +} + +.composerQueueItem[data-status='failed'] .composerQueueState { + color: var(--td-danger); +} + +.composerQueueItem[data-status='retrying'] .composerQueueState { + color: var(--td-warning); +} + +.composerQueueRetarget { + flex: none; + max-width: 160px; + padding: 2px 8px; + border: 1px solid var(--glass-border, var(--td-line)); + border-radius: var(--r-md); + background: var(--td-surface); + color: var(--td-ink-muted); + font: 12px/18px var(--td-font); +} + +.composerQueueRetarget:disabled { + opacity: 0.5; +} + +.composerQueueActions { + display: flex; + flex: none; + align-items: center; + gap: var(--sp-xxs); +} + +.composerQueueActionButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--glass-border, var(--td-line)); + border-radius: var(--r-md); + background: transparent; + color: var(--td-ink-muted); + font: 600 12px/1 var(--td-font); + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease); +} + +.composerQueueActionButton:disabled { + opacity: 0.4; + cursor: default; +} + +.composerQueueActionButton:hover:not(:disabled) { + background: var(--glass-bg-light, var(--td-surface-2)); + color: var(--td-ink); +} + +.composerQueueTextButton { + flex: none; + padding: 2px 10px; + border: 1px solid var(--glass-border, var(--td-line)); + border-radius: 999px; + background: transparent; + color: var(--td-ink-muted); + font: 600 11px/18px var(--td-font); + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease); +} + +.composerQueueTextButton:disabled { + opacity: 0.5; + cursor: default; +} + +.composerQueueTextButton:hover:not(:disabled) { + background: var(--glass-bg-light, var(--td-surface-2)); + color: var(--td-ink); } /* ── #1819 pending-approval reminder strip (badge/count + jump) ── */ @@ -3503,7 +3719,7 @@ .workspaceHeader, .mainchainStrip, .composer, - .pendingIntentBadge, + .composerQueue, .pendingApprovalStrip, .replyToBar, .quoteBar, diff --git a/app/workbench/src/ComposerDispatchQueue.test.tsx b/app/workbench/src/ComposerDispatchQueue.test.tsx new file mode 100644 index 000000000..03205c5eb --- /dev/null +++ b/app/workbench/src/ComposerDispatchQueue.test.tsx @@ -0,0 +1,194 @@ +// ComposerDispatchQueue behavior (#1965, UX F7): the visible dispatch-only +// queue for already-persisted Hub messages. Asserts order/target/preview/ +// retry-state visibility, undo/reorder/retarget/retry wiring, the disabled +// controls around dispatching rows, and honest rendering per status. +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { ComposerMention } from '@shared/composer'; +import { useTestI18nLanguage } from '@shared/testing/i18n'; + +import { ComposerDispatchQueue } from './ComposerDispatchQueue'; +import type { + PendingDispatchQueueItemView, + PendingIntentMove, +} from './composer/pendingIntents'; + +// Queue copy assertions use the zh chatview literals (integration-suite +// parity); opt into the zh bundle of the shared test i18next instance. +beforeAll(async () => { + await useTestI18nLanguage('zh'); +}); + +function item(overrides: Partial = {}): PendingDispatchQueueItemView { + return { + messageId: 'm-1', + agentId: 'builder', + agentLabel: 'Builder', + text: '第一条消息', + attempt: 0, + status: 'queued', + ...overrides, + }; +} + +function renderQueue( + items: PendingDispatchQueueItemView[], + handlers: { + onUndo?: (messageId: string) => void; + onMove?: (messageId: string, move: PendingIntentMove) => void; + onRetarget?: (messageId: string, targetId: string) => void; + onRetry?: (messageId: string) => void; + onClearAll?: () => void; + } = {}, + options: { isRunning?: boolean; retargetOptions?: ComposerMention[] } = {}, +) { + return render( + , + ); +} + +describe('ComposerDispatchQueue', () => { + it('renders nothing for an empty queue', () => { + const { container } = renderQueue([]); + expect(container.firstChild).toBeNull(); + }); + + it('shows order, text preview, target and status for every row', () => { + renderQueue([ + item(), + item({ messageId: 'm-2', text: '第二条消息', agentLabel: 'Reviewer', agentId: 'reviewer' }), + ]); + expect(screen.getByRole('region', { name: '待派发队列' })).toBeInTheDocument(); + expect(screen.getByText('待派发队列')).toBeInTheDocument(); + expect(screen.getByText('第一条消息')).toBeInTheDocument(); + expect(screen.getByText('第二条消息')).toBeInTheDocument(); + expect(screen.getByText('@Builder')).toBeInTheDocument(); + expect(screen.getByText('@Reviewer')).toBeInTheDocument(); + // Both rows show the queued status copy. + expect(screen.getAllByText('排队中')).toHaveLength(2); + // Idle summary explains that dispatch is about to happen in order. + expect(screen.getByText('2 条排队中,即将按序派发')).toBeInTheDocument(); + // The boundary notice: next-turn only, transcript survives undo. + expect(screen.getByText( + '队列只安排下一轮派单的顺序与目标,不会打断正在运行的任务;撤销只取消派单,已发送的消息保留在聊天记录中。', + )).toBeInTheDocument(); + }); + + it('labels the wait honestly while a run is in progress', () => { + renderQueue([item()], {}, { isRunning: true }); + expect(screen.getByText('1 条排队中,当前任务结束后按序派发')).toBeInTheDocument(); + }); + + it('wires undo / reorder / clear callbacks with the right row id', () => { + const onUndo = vi.fn(); + const onMove = vi.fn(); + const onClearAll = vi.fn(); + renderQueue( + [item(), item({ messageId: 'm-2', text: '第二条消息' }), item({ messageId: 'm-3', text: '第三条消息' })], + { onUndo, onMove, onClearAll }, + ); + fireEvent.click(screen.getByRole('button', { name: '撤销派单:第二条消息' })); + expect(onUndo).toHaveBeenCalledWith('m-2'); + fireEvent.click(screen.getByRole('button', { name: '置顶:第三条消息' })); + expect(onMove).toHaveBeenCalledWith('m-3', 'front'); + fireEvent.click(screen.getByRole('button', { name: '上移:第二条消息' })); + expect(onMove).toHaveBeenCalledWith('m-2', 'up'); + fireEvent.click(screen.getByRole('button', { name: '下移:第一条消息' })); + expect(onMove).toHaveBeenCalledWith('m-1', 'down'); + fireEvent.click(screen.getByRole('button', { name: '清空队列' })); + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + + it('disables reorder controls at boundaries', () => { + renderQueue([item(), item({ messageId: 'm-2', text: '第二条消息' })]); + expect(screen.getByRole('button', { name: '上移:第一条消息' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '置顶:第一条消息' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '下移:第二条消息' })).toBeDisabled(); + }); + + it('locks every control on a dispatching row and blocks moves across it', () => { + const onMove = vi.fn(); + renderQueue( + [ + item({ status: 'dispatching' }), + item({ messageId: 'm-2', text: '第二条消息' }), + item({ messageId: 'm-3', text: '第三条消息' }), + ], + { onMove }, + ); + expect(screen.getByRole('button', { name: '撤销派单:第一条消息' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '置顶:第一条消息' })).toBeDisabled(); + // The row behind the in-flight one cannot jump over it. + expect(screen.getByRole('button', { name: '置顶:第二条消息' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '上移:第二条消息' })).toBeDisabled(); + // Local reorder behind the in-flight row still works. + fireEvent.click(screen.getByRole('button', { name: '下移:第二条消息' })); + expect(onMove).toHaveBeenCalledWith('m-2', 'down'); + }); + + it('disables clear-all while every row is in flight', () => { + renderQueue([item({ status: 'dispatching' })]); + expect(screen.getByRole('button', { name: '清空队列' })).toBeDisabled(); + }); + + it('shows retry only for failed rows and renders the failure reason', () => { + const onRetry = vi.fn(); + const { unmount } = renderQueue( + [ + item({ status: 'failed', failureReason: 'dispatch-error' }), + item({ messageId: 'm-2', text: '第二条消息', status: 'failed', failureReason: 'retry-exhausted', attempt: 3 }), + ], + { onRetry }, + ); + expect(screen.getByText('派发失败')).toBeInTheDocument(); + expect(screen.getByText('重试 3 次仍被拒绝')).toBeInTheDocument(); + const retryButtons = screen.getAllByRole('button', { name: '重试' }); + expect(retryButtons).toHaveLength(2); + fireEvent.click(retryButtons[1]!); + expect(onRetry).toHaveBeenCalledWith('m-2'); + unmount(); + // Queued rows never expose a retry control. + renderQueue([item()]); + expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument(); + }); + + it('renders the retarget select only with 2+ dispatch targets and wires changes', () => { + const onRetarget = vi.fn(); + const twoAgents: ComposerMention[] = [ + { id: 'builder', label: 'Builder', dispatchRole: 'dispatch', status: 'available' }, + { id: 'reviewer', label: 'Reviewer', dispatchRole: 'dispatch', status: 'available' }, + ]; + renderQueue([item()], { onRetarget }, { retargetOptions: twoAgents }); + const select = screen.getByRole('combobox', { name: '更改派单目标:第一条消息' }); + fireEvent.change(select, { target: { value: 'reviewer' } }); + expect(onRetarget).toHaveBeenCalledWith('m-1', 'reviewer'); + }); + + it('hides the retarget control when there is no alternative target', () => { + const oneAgent: ComposerMention[] = [ + { id: 'builder', label: 'Builder', dispatchRole: 'dispatch', status: 'available' }, + ]; + renderQueue([item()], {}, { retargetOptions: oneAgent }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('disables retargeting while the row is already dispatching', () => { + const twoAgents: ComposerMention[] = [ + { id: 'builder', label: 'Builder', dispatchRole: 'dispatch', status: 'available' }, + { id: 'reviewer', label: 'Reviewer', dispatchRole: 'dispatch', status: 'available' }, + ]; + renderQueue([item({ status: 'dispatching' })], {}, { retargetOptions: twoAgents }); + expect(screen.getByRole('combobox', { name: '更改派单目标:第一条消息' })).toBeDisabled(); + }); +}); diff --git a/app/workbench/src/ComposerDispatchQueue.tsx b/app/workbench/src/ComposerDispatchQueue.tsx new file mode 100644 index 000000000..fd01193b7 --- /dev/null +++ b/app/workbench/src/ComposerDispatchQueue.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ComposerMention } from '@shared/composer'; +import { CHATVIEW_I18N_NAMESPACE } from '@shared/chatview/i18n/resources'; +import type { + PendingDispatchQueueItemView, + PendingIntentMove, +} from './composer/pendingIntents'; +import styles from './AgentHubWorkbench.module.css'; + +/** + * Visible dispatch-only queue for messages that are already persisted (#1965). + * Controls affect the next Hub task dispatch, never the run currently in flight. + */ +export interface ComposerDispatchQueueProps { + items: PendingDispatchQueueItemView[]; + isRunning: boolean; + retargetOptions?: ComposerMention[] | undefined; + onUndo: (messageId: string) => void; + onMove: (messageId: string, move: PendingIntentMove) => void; + onRetarget: (messageId: string, targetId: string) => void; + onRetry: (messageId: string) => void; + onClearAll: () => void; +} + +function statusKey(item: PendingDispatchQueueItemView): string { + if (item.status === 'failed') { + return item.failureReason === 'retry-exhausted' + ? 'composer.queue.statusRetryExhausted' + : 'composer.queue.statusFailed'; + } + if (item.status === 'dispatching') return 'composer.queue.statusDispatching'; + if (item.status === 'retrying') return 'composer.queue.statusRetrying'; + return 'composer.queue.statusQueued'; +} + +export const ComposerDispatchQueue = React.memo(function ComposerDispatchQueue({ + items, + isRunning, + retargetOptions, + onUndo, + onMove, + onRetarget, + onRetry, + onClearAll, +}: ComposerDispatchQueueProps): React.ReactElement | null { + const { t } = useTranslation(CHATVIEW_I18N_NAMESPACE); + if (items.length === 0) return null; + const showRetarget = (retargetOptions?.length ?? 0) >= 2; + const canClear = items.some((item) => item.status !== 'dispatching'); + + return ( +
+
+
+ {t('composer.queue.title')} + + {isRunning + ? t('composer.queue.summaryRunning', { count: items.length }) + : t('composer.queue.summaryIdle', { count: items.length })} + +
+ +
+

{t('composer.queue.notice')}

+
    + {items.map((item, index) => { + const dispatching = item.status === 'dispatching'; + const failed = item.status === 'failed'; + const previousDispatching = items[index - 1]?.status === 'dispatching'; + const nextDispatching = items[index + 1]?.status === 'dispatching'; + return ( +
  1. + + + {item.text} + + @{item.agentLabel} + + {t(statusKey(item), { attempt: item.attempt })} + + + + {showRetarget && ( + + )} + + {failed && ( + + )} + + + + + +
  2. + ); + })} +
+
+ ); +}); diff --git a/app/workbench/src/ConversationHost.tsx b/app/workbench/src/ConversationHost.tsx index ee6e3182b..76f777dc7 100644 --- a/app/workbench/src/ConversationHost.tsx +++ b/app/workbench/src/ConversationHost.tsx @@ -23,13 +23,20 @@ import { type ComposerDocumentFileDropCallbacks, } from './composerDocumentFileDrop'; import { + describePendingIntent, enqueuePendingIntent, + markPendingIntentDispatching, + markPendingIntentFailed, MAX_PENDING_DISPATCH_RETRIES, markPendingIntentRetried, - peekPendingIntent, + movePendingIntent, + peekDispatchablePendingIntent, PENDING_DISPATCH_RETRY_DELAY_MS, removePendingIntent, + retargetPendingIntent, + retryPendingIntent, type PendingDispatchIntent, + type PendingIntentMove, } from './composer/pendingIntents'; import type { AgentHubPlatform, WorkbenchConversation } from '@shared/platform'; import { AppError } from '@shared/errors'; @@ -38,6 +45,7 @@ import type { AttachmentUploadState } from './UnifiedComposer'; import type { FileItem } from './inspector'; import type { ConnectionStatusKind } from './GlobalRail'; import { ChatViewBridge } from './ChatViewBridge'; +import { ComposerDispatchQueue } from './ComposerDispatchQueue'; import { MainchainStatusStrip } from './MainchainStatusStrip'; import type { MainchainSummary } from './mainchain'; import { @@ -286,84 +294,160 @@ export const ConversationHost = React.memo(function ConversationHost({ setRunReviewOpen(false); }, []); - // ── Pending dispatch queue (CF22) ────────────────────────────────────── - // Queue is ref-authoritative (mutations are synchronous read-modify-write, - // never split across an await) with a version tick to trigger re-renders. - const [, bumpPendingIntentsVersion] = useState(0); - const pendingIntentsRef = useRef([]); - const isAgentRunningRef = useRef(false); - const flushInFlightRef = useRef(false); - - const mutatePendingIntents = useCallback( - (mutate: (current: PendingDispatchIntentEntry[]) => PendingDispatchIntentEntry[]) => { - pendingIntentsRef.current = mutate(pendingIntentsRef.current); - bumpPendingIntentsVersion((version) => version + 1); - }, - [], - ); + // ── Pending dispatch queue (CF22 / #1965) ───────────────────────────── + // Ref-authoritative queues are isolated by conversation. The active + // conversation's run signal can only flush its own queue; an inactive + // conversation can neither block nor accidentally dispatch another one. + const [pendingIntentsVersion, bumpPendingIntentsVersion] = useState(0); + const pendingIntentsByConversationRef = useRef>({}); + const runningByConversationRef = useRef>({}); + const flushInFlightConversationsRef = useRef>(new Set()); + const retryTimersRef = useRef>(new Map()); + const mountedRef = useRef(true); + const currentConversationIdRef = useRef(currentConversationId); + const flushPendingIntentsRef = useRef<(conversationId: string) => Promise>(async () => {}); + currentConversationIdRef.current = currentConversationId; - /** - * Retry the dispatch of the queue head (one at a time — a successful - * dispatch makes the agent busy again, so the rest wait for the next - * run end). Only `platform.runs.redispatchTask` is called: the message - * itself is never re-sent. - */ - const flushPendingIntents = useCallback(async (): Promise => { - if (flushInFlightRef.current) return; + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer); + retryTimersRef.current.clear(); + // Dispatch requests already handed to the platform cannot be recalled; + // cleanup only prevents hidden retries or state writes after unmount. + flushInFlightConversationsRef.current.clear(); + }; + }, []); + + const mutatePendingIntents = useCallback(( + conversationId: string, + mutate: (current: PendingDispatchIntentEntry[]) => PendingDispatchIntentEntry[], + ): void => { + if (!mountedRef.current) return; + const current = pendingIntentsByConversationRef.current[conversationId] ?? []; + const next = mutate(current); + if (next === current) return; + if (next.length === 0) delete pendingIntentsByConversationRef.current[conversationId]; + else pendingIntentsByConversationRef.current[conversationId] = next; + bumpPendingIntentsVersion((version) => version + 1); + }, []); + + const schedulePendingFlush = useCallback(( + conversationId: string, + delay = PENDING_DISPATCH_RETRY_DELAY_MS, + ): void => { + const existing = retryTimersRef.current.get(conversationId); + if (existing !== undefined) window.clearTimeout(existing); + const timer = window.setTimeout(() => { + retryTimersRef.current.delete(conversationId); + if (!mountedRef.current || currentConversationIdRef.current !== conversationId) return; + void flushPendingIntentsRef.current(conversationId); + }, delay); + retryTimersRef.current.set(conversationId, timer); + }, []); + + /** Dispatch one eligible row for the active conversation. */ + const flushPendingIntents = useCallback(async (conversationId: string): Promise => { + if (!mountedRef.current || currentConversationIdRef.current !== conversationId) return; + if (flushInFlightConversationsRef.current.has(conversationId)) return; const redispatchTask = platform.runs.redispatchTask; - if (!redispatchTask) return; // surface without separable dispatch: toast-only (unchanged behavior) - // Skip while a run is active: dispatch would 409 and burn a retry. The - // run-end transition flushes the queue at the right moment instead. - if (isAgentRunningRef.current) return; - const head = peekPendingIntent(pendingIntentsRef.current); - if (!head) return; - // The message lives in its original conversation — never dispatch a - // queued intent after the user has switched away. Surface the drop so it - // is not silent (#1821). - if (head.intent.conversationId !== currentConversationId) { - mutatePendingIntents((current) => removePendingIntent(current, head)); - onToast(t('toast.pendingDispatchDroppedOnSwitch', { defaultValue: '已切换会话,待派单任务已取消' })); - return; - } - flushInFlightRef.current = true; + if (!redispatchTask || runningByConversationRef.current[conversationId]) return; + const queue = pendingIntentsByConversationRef.current[conversationId] ?? []; + const nextIntent = peekDispatchablePendingIntent(queue); + if (!nextIntent) return; + + flushInFlightConversationsRef.current.add(conversationId); + mutatePendingIntents(conversationId, (current) => + markPendingIntentDispatching(current, nextIntent)); try { - const result = await redispatchTask(head.intent, head.messageId); - mutatePendingIntents((current) => { - const headNow = peekPendingIntent(current); - if (!headNow || headNow.messageId !== head.messageId) return current; - if (result.turnInProgress) { - const { queue: nextQueue, outcome } = markPendingIntentRetried(current, headNow); - if (outcome === 'abandoned') { - onToast(t('toast.dispatchRetryExhausted', { max: MAX_PENDING_DISPATCH_RETRIES })); - return nextQueue; - } - // Still busy — the run-end signal may have raced the Hub task - // status; give the status a moment and try the head once more. - if (!isAgentRunningRef.current) { - window.setTimeout(() => void flushPendingIntents(), PENDING_DISPATCH_RETRY_DELAY_MS); - } - return nextQueue; + const result = await redispatchTask(nextIntent.intent, nextIntent.messageId); + if (!mountedRef.current) return; + if (result.turnInProgress) { + let retryOutcome: 'requeued' | 'abandoned' | undefined; + mutatePendingIntents(conversationId, (current) => { + const currentEntry = current.find((item) => item.messageId === nextIntent.messageId); + if (!currentEntry) return current; + const resultState = markPendingIntentRetried(current, currentEntry); + retryOutcome = resultState.outcome; + return resultState.queue; + }); + if (retryOutcome === 'abandoned') { + onToast(t('toast.dispatchRetryExhausted', { max: MAX_PENDING_DISPATCH_RETRIES })); + } else if (!runningByConversationRef.current[conversationId]) { + schedulePendingFlush(conversationId); } - return removePendingIntent(current, headNow); + return; + } + mutatePendingIntents(conversationId, (current) => { + const currentEntry = current.find((item) => item.messageId === nextIntent.messageId); + return currentEntry ? removePendingIntent(current, currentEntry) : current; }); } catch (err) { - mutatePendingIntents((current) => removePendingIntent(current, head)); + mutatePendingIntents(conversationId, (current) => { + const currentEntry = current.find((item) => item.messageId === nextIntent.messageId); + return currentEntry ? markPendingIntentFailed(current, currentEntry) : current; + }); onToast(toastErrorCopy(t, err, 'toast.dispatchRetryFailed')); } finally { - flushInFlightRef.current = false; + flushInFlightConversationsRef.current.delete(conversationId); } - }, [platform, currentConversationId, mutatePendingIntents, onToast]); + }, [mutatePendingIntents, onToast, platform, schedulePendingFlush, t]); + flushPendingIntentsRef.current = flushPendingIntents; - // Agent run reached a terminal state (run.finished / run.failed / - // run.cancelled are folded into the shell's isAgentRunning signal) — - // flush any queued dispatch intents. + // A terminal run state only releases the ACTIVE conversation queue. When a + // conversation is revisited, its preserved queue is evaluated against that + // conversation's current run signal. useEffect(() => { - const wasRunning = isAgentRunningRef.current; - isAgentRunningRef.current = isAgentRunning ?? false; - if (wasRunning && !(isAgentRunning ?? false)) { - void flushPendingIntents(); - } - }, [isAgentRunning, flushPendingIntents]); + runningByConversationRef.current[currentConversationId] = isAgentRunning ?? false; + if (!(isAgentRunning ?? false)) schedulePendingFlush(currentConversationId, 0); + }, [currentConversationId, isAgentRunning, schedulePendingFlush]); + + const currentPendingIntents = pendingIntentsByConversationRef.current[currentConversationId] ?? []; + const pendingQueueItems = useMemo( + () => currentPendingIntents.map((entry) => describePendingIntent(entry)), + // The ref is authoritative; this version is the render invalidation token. + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentConversationId, pendingIntentsVersion], + ); + const retargetOptions = useMemo( + () => mentionableAgents.filter((agent) => + agent.dispatchRole !== 'context' && agent.status !== 'unavailable'), + [mentionableAgents], + ); + + const handlePendingUndo = useCallback((messageId: string): void => { + mutatePendingIntents(currentConversationId, (current) => { + const entry = current.find((item) => item.messageId === messageId); + if (!entry || entry.status === 'dispatching') return current; + return removePendingIntent(current, entry); + }); + onToast(t('composer.queue.undoConfirmed')); + }, [currentConversationId, mutatePendingIntents, onToast, t]); + + const handlePendingMove = useCallback((messageId: string, move: PendingIntentMove): void => { + mutatePendingIntents(currentConversationId, (current) => + movePendingIntent(current, messageId, move)); + }, [currentConversationId, mutatePendingIntents]); + + const handlePendingRetarget = useCallback((messageId: string, targetId: string): void => { + const target = retargetOptions.find((agent) => agent.id === targetId); + if (!target) return; + mutatePendingIntents(currentConversationId, (current) => + retargetPendingIntent(current, messageId, target)); + }, [currentConversationId, mutatePendingIntents, retargetOptions]); + + const handlePendingRetry = useCallback((messageId: string): void => { + mutatePendingIntents(currentConversationId, (current) => + retryPendingIntent(current, messageId)); + if (!(isAgentRunning ?? false)) schedulePendingFlush(currentConversationId, 0); + }, [currentConversationId, isAgentRunning, mutatePendingIntents, schedulePendingFlush]); + + const handlePendingClearAll = useCallback((): void => { + mutatePendingIntents(currentConversationId, (current) => + current.filter((entry) => entry.status === 'dispatching')); + onToast(t('composer.queue.clearConfirmed')); + }, [currentConversationId, mutatePendingIntents, onToast, t]); const displayTranscript = useMemo(() => { const chat = transcript.filter((b) => !isSidebarOnlyTranscriptBlock(b)); @@ -518,16 +602,17 @@ export const ConversationHost = React.memo(function ConversationHost({ if (submitResult.turnInProgress) { const dispatchMention = submitPayload.mentions.find((mention) => mention.dispatchRole !== 'context'); if (dispatchMention && platform.runs.redispatchTask) { - mutatePendingIntents((current) => enqueuePendingIntent(current, { + mutatePendingIntents(capturedConversationId, (current) => enqueuePendingIntent(current, { agentId: dispatchMention.id, messageId: submitResult.intentId, attempt: 0, + status: 'queued', intent: submitPayload, })); // If no run is reported active the busy window may already be over // (no run-end transition will fire) — schedule a short-delayed flush. if (!(isAgentRunning ?? false)) { - window.setTimeout(() => void flushPendingIntents(), PENDING_DISPATCH_RETRY_DELAY_MS); + schedulePendingFlush(capturedConversationId); } } onToast(t('toast.turnInProgress')); @@ -553,7 +638,7 @@ export const ConversationHost = React.memo(function ConversationHost({ onToast(toastErrorCopy(t, err, 'toast.submitFailed')); } finally { isSubmittingRef.current = false; } }, [composer, currentConversationId, platform, selectedExecutionTargetId, isAgentRunning, - onToast, dispatchComposer, t, transcript, onEditMessage, mutatePendingIntents, flushPendingIntents]); + onToast, dispatchComposer, t, transcript, onEditMessage, mutatePendingIntents, schedulePendingFlush]); /** * Retry a failed attachment upload from its chip (#1821). The attachment @@ -687,34 +772,16 @@ export const ConversationHost = React.memo(function ConversationHost({ /> {!selectionMode && ( <> - {pendingIntentsRef.current.length > 0 && ( -
- {t('toast.pendingDispatchBadge', { count: pendingIntentsRef.current.length })} - -
- )} + { +describe('pending dispatch queue (#1965)', () => { const draftKey = 'agenthub.composer.draft.team'; const dispatchDraft = { text: '继续修复聊天流', @@ -32,20 +34,47 @@ describe('pending dispatch queue (CF22)', () => { function renderQueueHarness( platform: ReturnType, isAgentRunning = true, + activeConversationId = 'team', ) { return render( , ); } - it('queues the dispatch intent on 409 turn_in_progress and re-dispatches when the run ends', async () => { + function workbenchProps( + platform: ReturnType, + isAgentRunning: boolean, + activeConversationId = 'team', + ) { + return { + agents: [{ id: 'builder', name: 'Builder', status: 'available', runtimeId: 'claude-code' }], + platform, + conversations: platform.seed.conversations, + activeConversationId, + transcript: [], + isAgentRunning, + }; + } + + async function submitWhileRunning() { + fireEvent.click(screen.getByRole('button', { name: '启动 Agent 任务' })); + // Queue title + the visible row for the persisted message's dispatch. + await waitFor(() => { + expect(screen.getByText('待派发队列')).toBeInTheDocument(); + }); + // The text renders at least twice: the surviving transcript message and + // the queue preview row (undo must never delete the former). + expect(screen.getAllByText('继续修复聊天流').length).toBeGreaterThanOrEqual(2); + } + + it('queues the dispatch intent on 409 turn_in_progress into a visible queue and re-dispatches when the run ends', async () => { window.localStorage.setItem(draftKey, JSON.stringify(dispatchDraft)); const platform = createMockPlatform({ surface: 'desktop', @@ -57,26 +86,13 @@ describe('pending dispatch queue (CF22)', () => { platform.runs.redispatchTask = redispatch; const { rerender } = renderQueueHarness(platform, true); - - // Stream-in-progress submit: message sent, dispatch rejected with 409. - fireEvent.click(screen.getByRole('button', { name: '启动 Agent 任务' })); - await waitFor(() => { - expect(submit).toHaveBeenCalledTimes(1); - }); - expect(screen.getByText('待发送 {count} 条')).toBeInTheDocument(); + await submitWhileRunning(); + // Running summary copy explains the wait (next-turn semantics). + expect(screen.getByText('1 条排队中,当前任务结束后按序派发')).toBeInTheDocument(); // The agent run ends → the queue flushes with a dispatch-only retry. // The message itself is never re-sent (submitComposerIntent stays at 1). - rerender( - , - ); + rerender(); await waitFor(() => { expect(redispatch).toHaveBeenCalledTimes(1); }); @@ -86,11 +102,11 @@ describe('pending dispatch queue (CF22)', () => { ); expect(submit).toHaveBeenCalledTimes(1); await waitFor(() => { - expect(screen.queryByText('待发送 {count} 条')).not.toBeInTheDocument(); + expect(screen.queryByText('待派发队列')).not.toBeInTheDocument(); }); }); - it('abandons a pending dispatch after 3 failed retries with a toast', async () => { + it('keeps the failed row visible after the retry budget is exhausted (no silent drop)', async () => { window.localStorage.setItem(draftKey, JSON.stringify(dispatchDraft)); const platform = createMockPlatform({ surface: 'desktop', @@ -102,33 +118,131 @@ describe('pending dispatch queue (CF22)', () => { platform.runs.redispatchTask = redispatch; const { rerender } = renderQueueHarness(platform, true); - fireEvent.click(screen.getByRole('button', { name: '启动 Agent 任务' })); - await waitFor(() => { - expect(submit).toHaveBeenCalledTimes(1); - }); - expect(screen.getByText('待发送 {count} 条')).toBeInTheDocument(); + await submitWhileRunning(); // Run ends → retry 1 → still 409 → requeued; the delayed retry loop burns // the remaining budget (2 more attempts) and abandons with a toast. - rerender( - , - ); + rerender(); await waitFor(() => { expect(redispatch).toHaveBeenCalledTimes(MAX_PENDING_DISPATCH_RETRIES); }, { timeout: 8000 }); - expect(screen.queryByText('待发送 {count} 条')).not.toBeInTheDocument(); - expect(screen.getByText('派单重试 {max} 次仍被拒绝,已放弃自动重试,请稍后手动重新触发该 Agent')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText( + '派单重试 {max} 次仍被拒绝,已放弃自动重试,请稍后手动重新触发该 Agent', + )).toBeInTheDocument(); + }); + // Contract: the abandoned row stays visible with a manual retry control + // instead of disappearing silently. + expect(screen.getByText('待派发队列')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(`重试 ${MAX_PENDING_DISPATCH_RETRIES} 次仍被拒绝`)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument(); // Never re-sent the message during the retry loop. expect(submit).toHaveBeenCalledTimes(1); }); + it('undo cancels only the pending dispatch — the persisted message is kept and nothing re-dispatches', async () => { + window.localStorage.setItem(draftKey, JSON.stringify(dispatchDraft)); + const platform = createMockPlatform({ + surface: 'desktop', + conversations: [{ id: 'team', title: 'Agent 协作群', kind: 'group' }], + }); + const submit = vi.fn().mockResolvedValue({ intentId: 'hub-msg-1', turnInProgress: true }); + const redispatch = vi.fn().mockResolvedValue({ taskId: 'task-1' }); + platform.runs.submitComposerIntent = submit; + platform.runs.redispatchTask = redispatch; + + const { rerender } = renderQueueHarness(platform, true); + await submitWhileRunning(); + + fireEvent.click(screen.getByRole('button', { name: '撤销派单:继续修复聊天流' })); + // Undo copy says the transcript message survives the cancel. + expect(screen.getByText('已撤销派单;消息仍保留在聊天记录中')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('待派发队列')).not.toBeInTheDocument(); + }); + + // Run ends with an empty queue — nothing left to dispatch. Give the + // idle flush timer a real window to fire so a leaked dispatch fails loud. + rerender(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(redispatch).not.toHaveBeenCalled(); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('isolates queues per conversation: another conversation neither sees nor flushes the queue', async () => { + window.localStorage.setItem(draftKey, JSON.stringify(dispatchDraft)); + const platform = createMockPlatform({ + surface: 'desktop', + conversations: [ + { id: 'team', title: 'Agent 协作群', kind: 'group' }, + { id: 'side', title: '旁路会话', kind: 'group' }, + ], + }); + const submit = vi.fn().mockResolvedValue({ intentId: 'hub-msg-1', turnInProgress: true }); + const redispatch = vi.fn().mockResolvedValue({ taskId: 'task-1' }); + platform.runs.submitComposerIntent = submit; + platform.runs.redispatchTask = redispatch; + + const { rerender } = renderQueueHarness(platform, true, 'team'); + await submitWhileRunning(); + + // Switch away while both runs are still active: the other conversation + // must not show the queue and must not trigger a dispatch. + rerender(); + await waitFor(() => { + expect(screen.queryByText('待派发队列')).not.toBeInTheDocument(); + }); + expect(redispatch).not.toHaveBeenCalled(); + + // Back to the owning conversation, run ended → only ITS queue flushes. + rerender(); + await waitFor(() => { + expect(redispatch).toHaveBeenCalledTimes(1); + }); + expect(redispatch).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: 'team' }), + 'hub-msg-1', + ); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('manual retry re-dispatches a failed row and drains the queue on success', async () => { + window.localStorage.setItem(draftKey, JSON.stringify(dispatchDraft)); + const platform = createMockPlatform({ + surface: 'desktop', + conversations: [{ id: 'team', title: 'Agent 协作群', kind: 'group' }], + }); + const submit = vi.fn().mockResolvedValue({ intentId: 'hub-msg-1', turnInProgress: true }); + const redispatch = vi.fn() + .mockRejectedValueOnce(new Error('hub dispatch exploded')) + .mockResolvedValue({ taskId: 'task-1' }); + platform.runs.submitComposerIntent = submit; + platform.runs.redispatchTask = redispatch; + + const { rerender } = renderQueueHarness(platform, true); + await submitWhileRunning(); + + // Run ends → first dispatch attempt throws → visible failed row + toast. + rerender(); + await waitFor(() => { + expect(screen.getByText('派发失败')).toBeInTheDocument(); + }); + expect(screen.getByText('派单重试失败,请手动重新触发该 Agent')).toBeInTheDocument(); + + // Manual retry resets the dispatch state and flushes again (message never + // re-sent: submit stays at 1). + fireEvent.click(screen.getByRole('button', { name: '重试' })); + await waitFor(() => { + expect(redispatch).toHaveBeenCalledTimes(2); + }); + await waitFor(() => { + expect(screen.queryByText('待派发队列')).not.toBeInTheDocument(); + }); + expect(submit).toHaveBeenCalledTimes(1); + }); + it('does not queue a 409 when the submit carried no dispatch mention', async () => { window.localStorage.setItem(draftKey, JSON.stringify({ text: '继续修复聊天流', @@ -145,7 +259,7 @@ describe('pending dispatch queue (CF22)', () => { await waitFor(() => { expect(platform.runs.submitComposerIntent).toHaveBeenCalledTimes(1); }); - expect(screen.queryByText(/待发送/)).not.toBeInTheDocument(); + expect(screen.queryByText('待派发队列')).not.toBeInTheDocument(); }); it('degrades to toast-only 409 behavior when the platform has no dispatch-only retry port', async () => { @@ -162,6 +276,7 @@ describe('pending dispatch queue (CF22)', () => { await waitFor(() => { expect(platform.runs.submitComposerIntent).toHaveBeenCalledTimes(1); }); - expect(screen.queryByText(/待发送/)).not.toBeInTheDocument(); + // Honest degradation: no queue UI, no fake controls — just the 409 toast. + expect(screen.queryByText('待派发队列')).not.toBeInTheDocument(); }); }); diff --git a/app/workbench/src/composer/pendingIntents.test.ts b/app/workbench/src/composer/pendingIntents.test.ts index 4ac0a2957..d0b1ee5d4 100644 --- a/app/workbench/src/composer/pendingIntents.test.ts +++ b/app/workbench/src/composer/pendingIntents.test.ts @@ -1,86 +1,148 @@ +// Pure-function contract for the client-side pending dispatch queue +// (#1965, UX F7; supersedes the CF22 badge-era semantics). The Hub message +// is already persisted before these rows exist; every operation is +// dispatch-only (never re-sends or edits the message) and only affects the +// NEXT dispatch, never the active run. Failed rows stay visible for manual +// action instead of disappearing silently. import { describe, expect, it } from 'vitest'; + +import type { ComposerIntent, ComposerMention } from '@shared/composer'; + import { + describePendingIntent, enqueuePendingIntent, + markPendingIntentDispatching, + markPendingIntentFailed, markPendingIntentRetried, MAX_PENDING_DISPATCH_RETRIES, + movePendingIntent, + peekDispatchablePendingIntent, peekPendingIntent, + PENDING_INTENT_PREVIEW_CHARS, removePendingIntent, + retargetPendingIntent, + retryPendingIntent, type PendingDispatchIntent, } from './pendingIntents'; +// ── Fixtures ────────────────────────────────────────────────────────── + +function mention(id: string, role: 'context' | 'dispatch' = 'dispatch'): ComposerMention { + return { id, label: `Agent ${id}`, dispatchRole: role }; +} + +function intent(text: string, mentions: ComposerMention[]): ComposerIntent { + return { + conversationId: 'team', + text, + mode: 'code', + mentions, + attachments: [], + approvalMode: 'suggest', + }; +} + function entry( - overrides: Partial> = {}, -): PendingDispatchIntent { + messageId: string, + agentId = 'builder', + overrides: Partial> = {}, +): PendingDispatchIntent { return { - agentId: 'builder', - messageId: `msg-${Math.random()}`, + agentId, + messageId, attempt: 0, - intent: { conversationId: 'team' }, + status: 'queued', + intent: intent(`text for ${messageId}`, [mention(agentId)]), ...overrides, }; } -describe('pendingIntents queue', () => { - it('enqueue appends at the tail (FIFO)', () => { - const first = entry({ messageId: 'm1' }); - const second = entry({ messageId: 'm2' }); - const queue = enqueuePendingIntent([], first); - const next = enqueuePendingIntent(queue, second); - expect(next).toHaveLength(2); - expect(peekPendingIntent(next)?.messageId).toBe('m1'); +// ── enqueue / peek / remove ─────────────────────────────────────────── + +describe('enqueuePendingIntent', () => { + it('appends FIFO at the tail', () => { + const queue = enqueuePendingIntent([entry('m-1')], entry('m-2')); + expect(queue.map((item) => item.messageId)).toEqual(['m-1', 'm-2']); }); - it('enqueue dedupes by messageId', () => { - const first = entry({ messageId: 'm1' }); - const queue = enqueuePendingIntent([], first); - const next = enqueuePendingIntent(queue, entry({ messageId: 'm1' })); + it('ignores a duplicate persisted message id', () => { + const queue = [entry('m-1')]; + const next = enqueuePendingIntent(queue, entry('m-1')); expect(next).toBe(queue); - expect(next).toHaveLength(1); }); +}); - it('peek returns the head without mutating the queue', () => { - const queue = [entry({ messageId: 'm1' }), entry({ messageId: 'm2' })]; - expect(peekPendingIntent(queue)?.messageId).toBe('m1'); +describe('peek helpers', () => { + it('peekPendingIntent returns the head without mutating, failed rows included', () => { + const queue = [entry('m-1', 'builder', { status: 'failed' }), entry('m-2')]; + expect(peekPendingIntent(queue)?.messageId).toBe('m-1'); expect(queue).toHaveLength(2); }); - it('peek returns undefined for an empty queue', () => { + it('peekPendingIntent returns undefined for an empty queue', () => { expect(peekPendingIntent([])).toBeUndefined(); }); - it('remove drops the entry by messageId regardless of position', () => { - const queue = [entry({ messageId: 'm1' }), entry({ messageId: 'm2' }), entry({ messageId: 'm3' })]; - const second = queue[1]; - expect(second).toBeDefined(); - const next = removePendingIntent(queue, second as NonNullable); - expect(next.map((item) => item.messageId)).toEqual(['m1', 'm3']); + it('peekDispatchablePendingIntent skips failed rows but keeps retrying rows', () => { + const queue = [ + entry('m-1', 'builder', { status: 'failed', failureReason: 'retry-exhausted' }), + entry('m-2', 'builder', { status: 'retrying', attempt: 1 }), + entry('m-3'), + ]; + expect(peekDispatchablePendingIntent(queue)?.messageId).toBe('m-2'); }); - it('retry bumps the attempt counter and keeps the entry at the head', () => { - const queue = [entry({ messageId: 'm1', attempt: 0 }), entry({ messageId: 'm2' })]; - const head = peekPendingIntent(queue); - expect(head).toBeDefined(); - const { queue: next, outcome } = markPendingIntentRetried(queue, head as NonNullable); + it('failed rows never block later dispatchable work', () => { + const queue = [entry('m-1', 'builder', { status: 'failed' }), entry('m-2')]; + expect(peekDispatchablePendingIntent(queue)?.messageId).toBe('m-2'); + }); +}); + +describe('removePendingIntent', () => { + it('removes only the dispatch intent, matched by message id', () => { + const queue = [entry('m-1'), entry('m-2'), entry('m-3')]; + const next = removePendingIntent(queue, queue[1]!); + expect(next.map((item) => item.messageId)).toEqual(['m-1', 'm-3']); + }); +}); + +// ── status transitions / retry budget ───────────────────────────────── + +describe('markPendingIntentDispatching', () => { + it('marks the row dispatching and clears any stale failure reason', () => { + const queue = [entry('m-1', 'builder', { status: 'failed', failureReason: 'dispatch-error' })]; + const next = markPendingIntentDispatching(queue, queue[0]!); + expect(next[0]).toMatchObject({ status: 'dispatching', failureReason: undefined }); + }); +}); + +describe('markPendingIntentRetried', () => { + it('requeues below the retry budget with an incremented attempt, head kept', () => { + const queue = [entry('m-1'), entry('m-2')]; + const { queue: next, outcome } = markPendingIntentRetried(queue, queue[0]!); expect(outcome).toBe('requeued'); - expect(next[0]?.messageId).toBe('m1'); - expect(next[0]?.attempt).toBe(1); + expect(next[0]).toMatchObject({ messageId: 'm-1', attempt: 1, status: 'retrying', failureReason: undefined }); expect(next).toHaveLength(2); }); - it('abandons the intent once the retry budget is exhausted', () => { - const queue = [entry({ messageId: 'm1', attempt: MAX_PENDING_DISPATCH_RETRIES - 1 })]; - const head = peekPendingIntent(queue); - expect(head).toBeDefined(); - const { queue: next, outcome } = markPendingIntentRetried(queue, head as NonNullable); + it('abandons at the retry budget but keeps the failed row visible', () => { + const exhausted = entry('m-1', 'builder', { attempt: MAX_PENDING_DISPATCH_RETRIES - 1 }); + const { queue, outcome } = markPendingIntentRetried([exhausted], exhausted); expect(outcome).toBe('abandoned'); - expect(next).toHaveLength(0); + // Contract: failed rows stay visible for manual retry — never dropped. + expect(queue).toHaveLength(1); + expect(queue[0]).toMatchObject({ + attempt: MAX_PENDING_DISPATCH_RETRIES, + status: 'failed', + failureReason: 'retry-exhausted', + }); }); - it('exactly MAX_PENDING_DISPATCH_RETRIES retries are allowed before abandon', () => { - let queue = [entry({ messageId: 'm1', attempt: 0 })]; + it('exactly MAX_PENDING_DISPATCH_RETRIES recoverable results then abandon', () => { + let queue = [entry('m-1')]; const outcomes: string[] = []; for (let retry = 0; retry < MAX_PENDING_DISPATCH_RETRIES + 2; retry += 1) { - const head = peekPendingIntent(queue); + const head = peekDispatchablePendingIntent(queue); if (!head) break; const result = markPendingIntentRetried(queue, head); queue = result.queue; @@ -89,6 +151,143 @@ describe('pendingIntents queue', () => { expect(outcomes).toHaveLength(MAX_PENDING_DISPATCH_RETRIES); expect(outcomes.slice(0, -1).every((outcome) => outcome === 'requeued')).toBe(true); expect(outcomes[outcomes.length - 1]).toBe('abandoned'); - expect(queue).toHaveLength(0); + // The abandoned row remains in the queue as a visible failed row. + expect(queue).toHaveLength(1); + expect(queue[0]?.status).toBe('failed'); + }); +}); + +describe('markPendingIntentFailed', () => { + it('keeps a non-recoverable dispatch error visible', () => { + const next = markPendingIntentFailed([entry('m-1')], entry('m-1')); + expect(next[0]).toMatchObject({ status: 'failed', failureReason: 'dispatch-error' }); + }); +}); + +describe('retryPendingIntent', () => { + it('resets only failed rows for a manual dispatch-only retry', () => { + const queue = [ + entry('m-1', 'builder', { status: 'failed', failureReason: 'retry-exhausted', attempt: 3 }), + entry('m-2'), + ]; + const next = retryPendingIntent(queue, 'm-1'); + expect(next[0]).toMatchObject({ status: 'queued', attempt: 0, failureReason: undefined }); + // Healthy rows are untouched. + expect(next[1]).toBe(queue[1]); + }); + + it('leaves non-failed rows unchanged', () => { + const queue = [entry('m-1')]; + expect(retryPendingIntent(queue, 'm-1')).toBe(queue); + }); +}); + +// ── reorder (next-turn dispatch order) ──────────────────────────────── + +describe('movePendingIntent', () => { + const three = () => [entry('m-1'), entry('m-2'), entry('m-3')]; + + it('moves to the front (置顶)', () => { + const next = movePendingIntent(three(), 'm-3', 'front'); + expect(next.map((item) => item.messageId)).toEqual(['m-3', 'm-1', 'm-2']); + }); + + it('moves up and down one slot', () => { + expect(movePendingIntent(three(), 'm-3', 'up').map((item) => item.messageId)) + .toEqual(['m-1', 'm-3', 'm-2']); + expect(movePendingIntent(three(), 'm-1', 'down').map((item) => item.messageId)) + .toEqual(['m-2', 'm-1', 'm-3']); + }); + + it('is a no-op at the list boundary', () => { + const queue = three(); + expect(movePendingIntent(queue, 'm-1', 'up')).toBe(queue); + expect(movePendingIntent(queue, 'm-3', 'down')).toBe(queue); + }); + + it('never reorders a dispatching row or swaps across one', () => { + const queue = [ + entry('m-1', 'builder', { status: 'dispatching' }), + entry('m-2'), + entry('m-3'), + ]; + // The in-flight row itself is immutable. + expect(movePendingIntent(queue, 'm-1', 'down')).toBe(queue); + // A queued row cannot jump across the in-flight row. + expect(movePendingIntent(queue, 'm-3', 'front')).toBe(queue); + expect(movePendingIntent(queue, 'm-2', 'up')).toBe(queue); + }); + + it('ignores unknown message ids', () => { + const queue = three(); + expect(movePendingIntent(queue, 'missing', 'front')).toBe(queue); + }); +}); + +// ── retarget (next dispatch agent) ──────────────────────────────────── + +describe('retargetPendingIntent', () => { + it('swaps the dispatch mention and agent id, keeping context mentions intact', () => { + const queue = [{ + ...entry('m-1'), + intent: intent('do it', [mention('docs', 'context'), mention('builder')]), + }]; + const next = retargetPendingIntent(queue, 'm-1', mention('reviewer')); + expect(next[0]?.agentId).toBe('reviewer'); + expect(next[0]?.intent.mentions.map((item) => `${item.id}:${item.dispatchRole}`)) + .toEqual(['docs:context', 'reviewer:dispatch']); + }); + + it('refuses context-role targets (context never dispatches)', () => { + const queue = [entry('m-1')]; + expect(retargetPendingIntent(queue, 'm-1', mention('docs', 'context'))).toBe(queue); + }); + + it('ignores dispatching rows and same-agent retargets', () => { + const dispatching = [entry('m-1', 'builder', { status: 'dispatching' })]; + expect(retargetPendingIntent(dispatching, 'm-1', mention('reviewer'))).toBe(dispatching); + const same = [entry('m-1')]; + expect(retargetPendingIntent(same, 'm-1', mention('builder'))).toBe(same); + }); +}); + +// ── view model ──────────────────────────────────────────────────────── + +describe('describePendingIntent', () => { + it('exposes order/target/text/status fields for the visible queue', () => { + const view = describePendingIntent(entry('m-1', 'builder', { status: 'retrying', attempt: 2 })); + expect(view).toMatchObject({ + messageId: 'm-1', + agentId: 'builder', + agentLabel: 'Agent builder', + text: 'text for m-1', + attempt: 2, + status: 'retrying', + }); + }); + + it('truncates long previews with an ellipsis', () => { + const long = '长'.repeat(PENDING_INTENT_PREVIEW_CHARS + 10); + const view = describePendingIntent({ + ...entry('m-1'), + intent: intent(long, [mention('builder')]), + }); + expect(view.text.length).toBe(PENDING_INTENT_PREVIEW_CHARS + 1); + expect(view.text.endsWith('…')).toBe(true); + }); + + it('falls back to the agent id when no dispatch mention label exists', () => { + const view = describePendingIntent({ + ...entry('m-1', 'builder'), + intent: intent('no mentions', []), + }); + expect(view.agentLabel).toBe('builder'); + }); + + it('propagates the failure reason for failed rows', () => { + const view = describePendingIntent( + entry('m-1', 'builder', { status: 'failed', failureReason: 'retry-exhausted' }), + ); + expect(view.failureReason).toBe('retry-exhausted'); }); }); diff --git a/app/workbench/src/composer/pendingIntents.ts b/app/workbench/src/composer/pendingIntents.ts index bff2485e3..fac6f8b37 100644 --- a/app/workbench/src/composer/pendingIntents.ts +++ b/app/workbench/src/composer/pendingIntents.ts @@ -1,57 +1,39 @@ -import type { ComposerIntent } from '@shared/composer'; +import type { ComposerIntent, ComposerMention } from '@shared/composer'; /** - * Client-side pending dispatch queue (CF22 / composer streaming spike ②). + * Client-side pending dispatch queue (CF22 / composer streaming spike ②, + * visible queue + steer semantics #1965). * - * When a streaming send hits the recoverable 409 `turn_in_progress` the Hub - * message is already persisted (SendMessage is independent) — only the - * agent-task dispatch was rejected. Instead of silently dropping the dispatch - * opportunity, ConversationHost enqueues a "dispatch intent" here and retries - * the dispatch (never the message) when the agent run reaches a terminal - * state. This module holds the pure queue logic only; state ownership and the - * redispatch call live in ConversationHost. + * The Hub message is already persisted before `turn_in_progress` is returned. + * Queue entries therefore represent dispatch-only work: they never resend, + * edit, recall, or delete the transcript message. + * + * AgentHub currently has no mid-run injection channel. Queue order and + * retargeting apply to the NEXT dispatch after the active run reaches a + * terminal state; they do not steer the run that is already executing. */ +export type PendingDispatchStatus = 'queued' | 'dispatching' | 'retrying' | 'failed'; + export interface PendingDispatchIntent { - /** - * Composer mention profile id of the dispatch target (@agent that carries - * `dispatchRole !== 'context'`). Informational for the badge; the platform - * resolves the Hub agent instance on redispatch. - */ + /** Dispatch target agent profile id. */ agentId: string; - /** - * Hub message id of the already-sent message. This is the `intentId` - * returned by `submitComposerIntent` and doubles as the retry trigger - * message for `triggerAgentTask`. - */ + /** Hub id of the message that is already persisted in the transcript. */ messageId: string; - /** - * Number of redispatch attempts already made (0 = freshly enqueued). - * The original submit is not counted — the retry budget is per redispatch. - */ + /** Number of dispatch-only retry attempts already made. */ attempt: number; - /** - * Full submit payload captured at enqueue time. Never re-sent as a message; - * used to resolve the dispatch target and to rebuild Hub model params. - */ + /** Visible queue state. Failed rows stay visible until retried or removed. */ + status: PendingDispatchStatus; + /** Stable failure category for user-facing status copy (never raw errors). */ + failureReason?: 'retry-exhausted' | 'dispatch-error' | undefined; + /** Captured dispatch payload. The message body is never re-sent. */ intent: Intent; } -/** - * Upper bound of redispatch attempts per intent. When a retry still returns - * 409 at this count the intent is abandoned and the user is told to retrigger - * manually (the message itself is already in the transcript). - */ export const MAX_PENDING_DISPATCH_RETRIES = 3; - -/** - * Delay used for the "busy window may already be over" fallback flush - * (enqueue while no run is reported active) — short enough to feel - * automatic, long enough for the Hub task status to settle. - */ export const PENDING_DISPATCH_RETRY_DELAY_MS = 1500; -/** Append a pending intent at the tail (FIFO). Dedupes by messageId. */ +/** Append at the tail (FIFO). Duplicate persisted message ids are ignored. */ export function enqueuePendingIntent( queue: PendingDispatchIntent[], entry: PendingDispatchIntent, @@ -60,14 +42,21 @@ export function enqueuePendingIntent( return [...queue, entry]; } -/** Peek the head of the queue without mutating it. */ +/** First row, including failed rows (view/order helper). */ export function peekPendingIntent( queue: PendingDispatchIntent[], ): PendingDispatchIntent | undefined { - return queue.length > 0 ? queue[0] : undefined; + return queue[0]; } -/** Remove an intent by messageId (identity of a dispatch entry). */ +/** First entry eligible for the next dispatch; failed rows never block later work. */ +export function peekDispatchablePendingIntent( + queue: PendingDispatchIntent[], +): PendingDispatchIntent | undefined { + return queue.find((item) => item.status === 'queued' || item.status === 'retrying'); +} + +/** Remove one dispatch intent only. The persisted transcript message remains. */ export function removePendingIntent( queue: PendingDispatchIntent[], entry: PendingDispatchIntent, @@ -75,26 +64,161 @@ export function removePendingIntent( return queue.filter((item) => item.messageId !== entry.messageId); } -export type PendingDispatchRetryOutcome = 'requeued' | 'abandoned'; +function replacePendingIntent( + queue: PendingDispatchIntent[], + messageId: string, + replace: (entry: PendingDispatchIntent) => PendingDispatchIntent, +): PendingDispatchIntent[] { + const index = queue.findIndex((item) => item.messageId === messageId); + if (index < 0) return queue; + const entry = queue[index]; + if (!entry) return queue; + const nextEntry = replace(entry); + if (nextEntry === entry) return queue; + const next = [...queue]; + next[index] = nextEntry; + return next; +} + +export function markPendingIntentDispatching( + queue: PendingDispatchIntent[], + entry: PendingDispatchIntent, +): PendingDispatchIntent[] { + return replacePendingIntent(queue, entry.messageId, (current) => ({ + ...current, + status: 'dispatching', + failureReason: undefined, + })); +} /** - * Record one failed redispatch (409 turn_in_progress) for the queue head. - * Keeps the entry at the head (the whole queue waits for the head anyway) and - * bumps the attempt counter. When the counter hits the retry budget the entry - * is dropped and 'abandoned' is returned so the caller can surface a toast. + * Record a recoverable `turn_in_progress` result. The row stays visible after + * the retry budget is exhausted instead of disappearing silently. */ export function markPendingIntentRetried( queue: PendingDispatchIntent[], entry: PendingDispatchIntent, -): { queue: PendingDispatchIntent[]; outcome: PendingDispatchRetryOutcome } { +): { queue: PendingDispatchIntent[]; outcome: 'requeued' | 'abandoned' } { const nextAttempt = entry.attempt + 1; - if (nextAttempt >= MAX_PENDING_DISPATCH_RETRIES) { - return { queue: removePendingIntent(queue, entry), outcome: 'abandoned' }; - } + const abandoned = nextAttempt >= MAX_PENDING_DISPATCH_RETRIES; + return { + queue: replacePendingIntent(queue, entry.messageId, (current) => ({ + ...current, + attempt: nextAttempt, + status: abandoned ? 'failed' : 'retrying', + ...(abandoned + ? { failureReason: 'retry-exhausted' as const } + : { failureReason: undefined }), + })), + outcome: abandoned ? 'abandoned' : 'requeued', + }; +} + +/** Keep a non-recoverable dispatch error visible for explicit manual action. */ +export function markPendingIntentFailed( + queue: PendingDispatchIntent[], + entry: PendingDispatchIntent, +): PendingDispatchIntent[] { + return replacePendingIntent(queue, entry.messageId, (current) => ({ + ...current, + status: 'failed', + failureReason: 'dispatch-error', + })); +} + +/** Manual retry resets only dispatch state; it never re-sends the message. */ +export function retryPendingIntent( + queue: PendingDispatchIntent[], + messageId: string, +): PendingDispatchIntent[] { + return replacePendingIntent(queue, messageId, (entry) => { + if (entry.status !== 'failed') return entry; + return { + ...entry, + attempt: 0, + status: 'queued', + failureReason: undefined, + }; + }); +} + +export type PendingIntentMove = 'front' | 'up' | 'down'; + +/** + * Reorder NEXT-turn dispatch work. Dispatching rows are immutable because the + * request has already crossed the platform boundary. + */ +export function movePendingIntent( + queue: PendingDispatchIntent[], + messageId: string, + move: PendingIntentMove, +): PendingDispatchIntent[] { + const index = queue.findIndex((item) => item.messageId === messageId); + if (index < 0) return queue; + const entry = queue[index]; + if (!entry || entry.status === 'dispatching') return queue; + const targetIndex = move === 'front' ? 0 : move === 'up' ? index - 1 : index + 1; + if (targetIndex === index || targetIndex < 0 || targetIndex >= queue.length) return queue; + if (queue[targetIndex]?.status === 'dispatching') return queue; + const next = [...queue]; + next.splice(index, 1); + next.splice(targetIndex, 0, entry); + return next; +} + +/** Change the agent target for the NEXT dispatch; context mentions stay intact. */ +export function retargetPendingIntent( + queue: PendingDispatchIntent[], + messageId: string, + target: ComposerMention, +): PendingDispatchIntent[] { + if (target.dispatchRole === 'context') return queue; + return replacePendingIntent(queue, messageId, (entry) => { + if (entry.status === 'dispatching') return entry; + const dispatchIndex = entry.intent.mentions.findIndex( + (mention) => mention.dispatchRole !== 'context', + ); + if (dispatchIndex < 0 || entry.agentId === target.id) return entry; + const mentions = entry.intent.mentions.map((mention, index) => + index === dispatchIndex ? target : mention, + ); + return { + ...entry, + agentId: target.id, + intent: { ...entry.intent, mentions }, + }; + }); +} + +export const PENDING_INTENT_PREVIEW_CHARS = 80; + +export interface PendingDispatchQueueItemView { + messageId: string; + agentId: string; + agentLabel: string; + text: string; + attempt: number; + status: PendingDispatchStatus; + failureReason?: PendingDispatchIntent['failureReason']; +} + +export function describePendingIntent( + entry: PendingDispatchIntent, +): PendingDispatchQueueItemView { + const rawText = entry.intent.text.trim(); + const text = rawText.length > PENDING_INTENT_PREVIEW_CHARS + ? `${rawText.slice(0, PENDING_INTENT_PREVIEW_CHARS)}…` + : rawText; + const dispatchMention = entry.intent.mentions.find( + (mention) => mention.dispatchRole !== 'context', + ); return { - queue: queue.map((item) => - item.messageId === entry.messageId ? { ...item, attempt: nextAttempt } : item, - ), - outcome: 'requeued', + messageId: entry.messageId, + agentId: entry.agentId, + agentLabel: dispatchMention?.label ?? entry.agentId, + text, + attempt: entry.attempt, + status: entry.status, + ...(entry.failureReason ? { failureReason: entry.failureReason } : {}), }; }