Recover terminal work and preserve explicit navigation intent

Why: Background frames blocked terminal reconciliation, stale failures left confirmations busy, and delayed page hydration changed mobile navigation.

Evidence: Review findings #1/#2/#3 have recorded red-to-green checks; all 210 Frontend tests, tsc and knip pass, plus six real navigation browser checks.

Impact: Keep sequence high-water and operation ownership, merge observations by object, and use an explicit preview-opening intent.

Refs: ce-code-review 20261012-165912-cb241b46 #1/#2/#3, UI04/UI09/UI10/UI11
This commit is contained in:
2026-10-12 18:55:55 +00:00
parent e6eecb2bfa
commit 37c668dbad
4 changed files with 419 additions and 28 deletions
+141
View File
@@ -0,0 +1,141 @@
import { expect, test, type Page } from '@playwright/test'
import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
const stamp = '2026-10-12T10:00:00Z'
const conversation: Conversation = {
id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料',
createdAt: stamp, updatedAt: stamp,
}
const artifact: Artifact = {
id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown',
size: 64, createdAt: stamp, updatedAt: stamp,
}
const snapshot: SurfaceSnapshot = {
identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
spec: { title: '已保存的材料页面', nodes: [
{ id: 'summary', type: 'markdown', markdown: '已保存的材料范围' },
{ id: 'source', type: 'doc-ref', docId: artifact.id },
] },
}
const metadata: SurfaceMeta = {
surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId,
revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp,
}
// Delay the external list contract, while running the real store, App and panes.
// The saved selection is restored by openConversation rather than test-only state injection.
async function fixture(page: Page) {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
let release!: () => void
const hydration = new Promise<void>(resolve => { release = resolve })
await page.addInitScript(({ conversationId }) => {
localStorage.setItem('as.conv', conversationId)
localStorage.setItem('as.ui', JSON.stringify({
theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true,
}))
}, { conversationId: conversation.id })
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' })
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false }
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }]
else if (path === '/api/conversations') body = [conversation]
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] }
else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot
else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] }
else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact]
else if (path === `/api/artifacts/${artifact.id}/content`) {
return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' })
} else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] }
else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区',
body: [{ type: 'doc-ref', docId: artifact.id }] }]
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`)
await page.goto('/')
await listRequest
await expect(page.locator('.chat-title')).toHaveText(conversation.title)
return { release, errors }
}
for (const width of [736, 390]) {
test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true })
await search.fill('材料')
await expect(search).toBeFocused()
state.release()
// The real hidden preview proves both restored tab selection and snapshot hydration finished.
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context')
await expect(page.locator('.left-nav')).toBeVisible()
await expect(page.locator('.main')).toBeHidden()
await expect(page.locator('.right-panel')).toBeHidden()
await expect(search).toHaveValue('材料')
await expect(search).toBeFocused()
expect(state.errors).toEqual([])
})
test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const state = await fixture(page)
state.release()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work')
await expect(page.locator('.right-panel')).toBeVisible()
await expect(page.locator('.left-nav')).toBeHidden()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.getByRole('button', { name: '打开文件', exact: true }).click()
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
await page.getByRole('button', { name: '返回文件列表', exact: true }).click()
await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
// Surface doc-ref uses the existing document bridge, which opens the same preview.
await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click()
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
await page.getByRole('button', { name: '展开预览', exact: true }).click()
await expect(page.locator('.right-panel')).toHaveClass(/expanded/)
await page.getByRole('button', { name: '返回对话', exact: true }).click()
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
await expect(page.locator('.main')).toBeVisible()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.setViewportSize({ width: 1440, height: 900 })
await expect(page.locator('.main')).toBeVisible()
await page.setViewportSize({ width, height: 900 })
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
await expect(page.locator('.left-nav')).toBeHidden()
expect(state.errors).toEqual([])
})
test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const state = await fixture(page)
state.release()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
await page.getByRole('button', { name: '打开文件', exact: true }).click()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click()
const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true })
await workspace.getByRole('button', { name: artifact.name, exact: true }).click()
await page.keyboard.press('Escape')
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
expect(state.errors).toEqual([])
})
}
+3 -3
View File
@@ -30,7 +30,7 @@ export function App() {
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const previewActive = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
const previewOpenIntent = useStore(s => s.previewOpenIntent)
const rightTab = useStore(s => s.ui.rightTab)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
@@ -50,8 +50,8 @@ export function App() {
return () => window.removeEventListener('resize', onResize)
}, [])
// An explicit file/work opening leaves mobile context. Card updates do not change this choice.
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, previewActive, ui.filesView])
// Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice.
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent])
useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact])
const returnToConversation = () => {
+110 -24
View File
@@ -136,6 +136,8 @@ interface AppState {
outboxDraining: boolean
previewTabs: Record<string, PreviewTab[]>
previewActive: Record<string, string | null>
/** Ephemeral explicit opening intent; restoring saved metadata must not advance it. */
previewOpenIntent: number
}
const DEFAULT_UI: UIState = {
@@ -194,7 +196,7 @@ let state: AppState = {
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
previewTabs: {}, previewActive: {}, previewOpenIntent: 0,
}
const listeners = new Set<() => void>()
@@ -279,6 +281,12 @@ let projectSeq = 0
let conversationEpoch = 0
let streamSeq = 0
let detailRequest = 0
let detailSeq = 0
let detailVersion = 0
type DetailObservation = { version: number; seq: number; terminalSeq?: number; terminalVersion?: number }
let runObservations: Record<string, DetailObservation> = {}
let messageObservations: Record<string, DetailObservation> = {}
let hitlObservations: Record<string, DetailObservation> = {}
let cardsRequest = 0
let projectCardsRequest = 0
let cardVersion = 0
@@ -288,6 +296,41 @@ let projectCardVersions: Record<string, number> = {}
let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
observations[id] = { ...observations[id], version, seq,
...(terminal ? { terminalSeq: seq, terminalVersion: version } : {}) }
}
function detailRowIsNewer(observations: Record<string, DetailObservation>, id: string, started: number, seq: number) {
const observation = observations[id]
return observation && (observation.version > started || observation.seq > seq)
}
function terminalObservationIsNewer(id: string, started: number, seq: number) {
const observation = runObservations[id]
return (observation?.terminalVersion ?? 0) > started || (observation?.terminalSeq ?? 0) > seq
}
function mergeDetailRows<T extends { id: string }>(snapshot: T[], current: T[], observations: Record<string, DetailObservation>, started: number, seq: number) {
const fresh = current.filter(row => detailRowIsNewer(observations, row.id, started, seq))
const byId = new Map(fresh.map(row => [row.id, row]))
const snapshotIds = new Set(snapshot.map(row => row.id))
return [...snapshot.map(row => byId.get(row.id) ?? row), ...fresh.filter(row => !snapshotIds.has(row.id))]
}
function scheduleTerminalReconcile() {
const convId = state.currentConvId
const epoch = conversationEpoch
setTimeout(() => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
void store.reconcile().then(() => {
if (state.currentConvId === convId && conversationEpoch === epoch) return store.flushOutbox()
})
}, 250)
}
// Snapshot queries may overlap events. Only rows changed after the request began
// override that snapshot; a newer request/context invalidates the old query entirely.
@@ -306,18 +349,26 @@ function hitlSource(card: ActivityCard): string | undefined {
}
}
function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = []) {
function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = [], owner?: symbol) {
let states: AppState['cardActionStates'] | undefined
for (const id of [key, ...ids]) {
if (owner && actionFeedbackOwners[id] !== owner) continue
const current = states ?? state.cardActionStates
if (value ? current[id] === value : !(id in current)) continue
states ??= { ...state.cardActionStates }
if (value) states[id] = value
else delete states[id]
else { delete states[id]; delete actionFeedbackOwners[id] }
}
if (states) set({ cardActionStates: states })
}
function startActionFeedback(key: string, ids: string[] = []) {
const owner = Symbol(key)
for (const id of [key, ...ids]) actionFeedbackOwners[id] = owner
actionFeedback(key, 'submitting', ids, owner)
return owner
}
function settleCardFeedback(cards: ActivityCard[]) {
let states: AppState['cardActionStates'] | undefined
for (const card of cards) {
@@ -327,6 +378,7 @@ function settleCardFeedback(cards: ActivityCard[]) {
if (!(id in (states ?? state.cardActionStates))) continue
states ??= { ...state.cardActionStates }
delete states[id]
delete actionFeedbackOwners[id]
}
}
if (states) set({ cardActionStates: states })
@@ -395,6 +447,7 @@ export const store = {
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
@@ -470,6 +523,7 @@ export const store = {
if (sse) { sse.close(); sse = null }
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
persistConvId(convId)
@@ -519,16 +573,31 @@ export const store = {
const convId = state.currentConvId
const epoch = conversationEpoch
const request = ++detailRequest
const started = detailVersion
if (!convId) return
try {
const d = await api.conversationDetail(convId)
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < state.lastSeq) return
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < detailSeq) return
// A background frame may overtake this snapshot without changing its
// messages/runs. Preserve only newer observations of the same object.
const messages = mergeDetailRows(d.messages, state.messages, messageObservations, started, d.lastSeq)
const snapshotRuns = new Map(d.runs.map(run => [run.id, run]))
const runs = mergeDetailRows(d.runs, state.runs, runObservations, started, d.lastSeq).map(run => {
const snapshot = snapshotRuns.get(run.id)
const newerTerminal = terminalObservationIsNewer(run.id, started, d.lastSeq)
return snapshot && isTerminal(snapshot.status) && !isTerminal(run.status) && !newerTerminal ? snapshot : run
})
const activeHitl = mergeDetailRows(d.activeHitl, state.activeHitl, hitlObservations, started, d.lastSeq)
.filter(card => !detailRowIsNewer(hitlObservations, card.id, started, d.lastSeq) || state.activeHitl.some(c => c.id === card.id))
const live = state.live
const liveRunActive = live && (!isTerminal(runs.find(r => r.id === live.runId)?.status) ||
(terminalObservationIsNewer(live.runId, started, d.lastSeq) && !messages.some(m => m.role === 'assistant' && m.runId === live.runId)))
const resumeSeq = d.resumeSeq ?? d.lastSeq
detailSeq = d.lastSeq
set({
messages: d.messages,
runs: d.runs,
activeHitl: d.activeHitl,
messages,
runs,
activeHitl,
lastSeq: Math.max(state.lastSeq, d.lastSeq),
live: liveRunActive ? state.live : null,
connected: true,
@@ -565,6 +634,7 @@ export const store = {
// persistent card/plan/status snapshot already returned by the detail query.
if (e.seq <= previousSeq && !liveEventTypes.has(e.type)) return
set({ lastSeq: Math.max(previousSeq, e.seq) })
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
switch (e.type) {
@@ -612,11 +682,18 @@ export const store = {
break
}
case 'hitl.request': {
const card = e.payload as HITLCard
if (card?.id) {
observeDetail(hitlObservations, card.id, e.seq)
set({ activeHitl: [...state.activeHitl.filter(c => c.id !== card.id), card] })
}
store.reconcile()
break
}
case 'hitl.resolved': {
set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) })
const cardId = (e.payload as { cardId: string }).cardId
observeDetail(hitlObservations, cardId, e.seq)
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
break
}
case 'agent.spawn': {
@@ -710,17 +787,19 @@ export const store = {
break
}
case 'run.completed': {
if (e.runId) observeDetail(runObservations, e.runId, e.seq, true)
const p = e.payload as { usage?: unknown; context?: ContextObs }
if (p?.context && e.runId) {
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
}
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
scheduleTerminalReconcile()
break
}
case 'run.failed':
case 'error': {
if (e.runId && e.type === 'run.failed') observeDetail(runObservations, e.runId, e.seq, true)
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
scheduleTerminalReconcile()
break
}
default: {
@@ -749,6 +828,8 @@ export const store = {
}
if (out.run) {
const started = out.run
observeDetail(runObservations, started.id)
observeDetail(messageObservations, out.message.id)
// 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。
const runs = mergeStartedRun(state.runs, started)
const messages = state.messages.some(m => m.id === out.message.id)
@@ -843,12 +924,12 @@ export const store = {
const convId = state.currentConvId
const epoch = conversationEpoch
actionInflight.add(key)
actionFeedback(key, 'submitting', related)
const feedbackOwner = startActionFeedback(key, related)
try {
const out = await api.resolveHITL(cardId, resp)
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
if (!out.resolved) {
actionFeedback(key, 'unknown', related)
actionFeedback(key, 'unknown', related, feedbackOwner)
await store.reconcile()
return
}
@@ -857,21 +938,21 @@ export const store = {
// outcome (including cancellation) instead of stamping every choice resolved.
if (convId) await store.refreshCards(convId)
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
actionFeedback(key)
actionFeedback(key, undefined, [], feedbackOwner)
for (const id of related) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === id)
actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined)
actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
if (out.pivoted && out.pivotText) {
await store.sendMessage(out.pivotText, [])
}
} catch (e) {
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
actionFeedback(key, rejected ? undefined : 'unknown', related, feedbackOwner)
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
if (/^4\d\d:/.test(String(e instanceof Error ? e.message : e))) {
actionFeedback(key, undefined, related)
if (rejected) {
store.toast('本次确认未被接受,请查看当前事项后再处理')
} else {
actionFeedback(key, 'unknown', related)
store.toast('确认结果暂时未知,正在核实处理状态')
await store.reconcile()
}
@@ -918,7 +999,7 @@ export const store = {
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
actionFeedback(card.id, 'submitting')
const feedbackOwner = startActionFeedback(card.id)
try {
const out = await api.cardAction(card.id, actionId, freeText)
if (!sameContext()) return
@@ -929,7 +1010,10 @@ export const store = {
const cards = state.projectCards
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
}
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) set({ messages: [...state.messages, out.receipt as Message] })
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
observeDetail(messageObservations, out.receipt.id)
set({ messages: [...state.messages, out.receipt as Message] })
}
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
if (!newer && card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
@@ -937,15 +1021,15 @@ export const store = {
}
if (sameContext()) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
} catch (e) {
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
actionFeedback(card.id, rejected ? undefined : 'unknown', [], feedbackOwner)
if (!sameContext()) return
if (/^4\d\d:/.test(String(e instanceof Error ? e.message : e))) {
actionFeedback(card.id)
if (rejected) {
store.toast('本次操作未被接受,请查看当前事项后再处理')
} else {
actionFeedback(card.id, 'unknown')
store.toast('操作结果暂时未知,正在核实处理状态')
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
else await store.loadProjectCards()
@@ -1151,6 +1235,7 @@ export const store = {
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
previewOpenIntent: state.previewOpenIntent + 1,
})
syncFilesEntry(true)
},
@@ -1162,6 +1247,7 @@ export const store = {
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
previewOpenIntent: state.previewOpenIntent + 1,
})
syncFilesEntry(true)
},
+165 -1
View File
@@ -41,9 +41,104 @@ beforeEach(async () => {
await store.openConversation('a')
await store.refreshCards('a')
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('work projection ordering and context', () => {
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
vi.useFakeTimers()
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
store.upsertCard(card('background'))
store.enqueueOutbox('继续整理', ['material'], ['材料'])
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const send = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
const submission = vi.spyOn(api, 'sendMessage').mockReturnValue(send.promise)
store.applyEvent(event(2, 'run.completed', {}, 'front'))
await vi.advanceTimersByTimeAsync(250)
store.applyEvent(event(3, 'agent.progress', { agentRun: 'background', note: '仍在核对' }, 'background'))
store.applyEvent(event(4, 'card.status', { cardId: 'background', status: 'done' }, 'background'))
vi.mocked(api.listCards).mockResolvedValue([card('background', 'done')])
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 2), messages: [
{ id: 'reply', conversationId: 'a', runId: 'front', role: 'assistant', parts: [{ type: 'text', text: '整理完成' }], createdAt: '' },
] })
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().runs.find(r => r.id === 'front')?.status).toBe('completed')
expect(store.getState().messages.map(m => m.id)).toContain('reply')
expect(store.getState().live).toBeNull()
expect(store.getState().lastSeq).toBe(4)
expect(store.getState().cards.find(c => c.id === 'background')?.status).toBe('done')
expect(store.getState().outbox).toEqual([])
expect(submission).toHaveBeenCalledWith('a', '继续整理', ['material'], expect.any(String))
send.resolve({ message: { id: 'queued', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
await vi.advanceTimersByTimeAsync(0)
})
it('waits for the newer terminal run snapshot before settling its live output', async () => {
vi.useFakeTimers()
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
store.applyEvent(event(2, 'run.failed', { code: 'failed', message: '未完成' }, 'front'))
query.resolve(detail('a', [{ ...run, status: 'completed' }], 1))
await pending
expect(store.getState().runs[0].status).toBe('running')
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '正在整理' })
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [{ ...run, status: 'failed' }], 2))
await vi.advanceTimersByTimeAsync(250)
expect(store.getState().runs[0].status).toBe('failed')
expect(store.getState().live).toBeNull()
})
it('does not restore a resolved HITL from a detail snapshot overtaken by its resolution', async () => {
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
await store.openConversation('a')
const query = deferred<Awaited<ReturnType<typeof api.conversationDetail>>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
store.applyEvent(event(2, 'hitl.resolved', { cardId: 'ask' }, 'front'))
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 1), activeHitl: [
{ id: 'ask', runId: 'front', conversationId: 'a', question: '继续?', kind: 'confirm', allowFreeInput: false, createdAt: '', resolved: false },
] })
await pending
expect(store.getState().activeHitl).toEqual([])
expect(store.getState().runs[0].status).toBe('completed')
expect(store.getState().lastSeq).toBe(2)
})
it('preserves a run and user message started after a detail query began', async () => {
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
vi.spyOn(api, 'sendMessage').mockResolvedValue({
message: { id: 'new-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' },
run: { id: 'new-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' },
})
await store.sendMessage('继续', [])
query.resolve(detail('a'))
await pending
expect(store.getState().runs.map(r => r.id)).toEqual(['new-run'])
expect(store.getState().messages.map(m => m.id)).toEqual(['new-message'])
expect(store.getState().live?.runId).toBe('new-run')
})
it('drops a terminal reconciliation timer after leaving its conversation', async () => {
vi.useFakeTimers()
store.applyEvent(event(1, 'run.completed', {}, 'front'))
await store.openConversation('b')
const calls = vi.mocked(api.conversationDetail).mock.calls.length
await vi.advanceTimersByTimeAsync(250)
expect(api.conversationDetail).toHaveBeenCalledTimes(calls)
expect(store.getState().currentConvId).toBe('b')
})
it('does not regress a finished card on an older or duplicated event', () => {
store.applyEvent(event(10, 'card.created', { card: card() }))
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'done' }))
@@ -153,6 +248,75 @@ describe('work projection ordering and context', () => {
})
describe('shared action feedback', () => {
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }
store.upsertCard(confirm)
const response = deferred<never>()
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const pending = store.cardAction(confirm, 'confirm')
await store.openConversation('b')
response.reject(new Error('400: rejected'))
await pending
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
expect(store.getState().cards).toEqual([])
expect(store.getState().messages).toEqual([])
vi.mocked(api.listCards).mockResolvedValue([confirm])
await store.openConversation('a')
await store.refreshCards('a')
await store.cardAction(confirm, 'confirm')
expect(request).toHaveBeenCalledTimes(2)
})
it.each([
['card', new TypeError('connection lost')], ['card', new Error('503: unavailable')],
['hitl', new TypeError('connection lost')], ['hitl', new Error('503: unavailable')],
] as const)('retains unknown feedback for an off-context %s request with %s', async (kind, failure) => {
const id = `unknown-${kind}-${failure instanceof TypeError ? 'network' : 'server'}`
const confirm = { ...card(id, 'action_required'), kind: 'confirm' as const,
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:${id}`]] as [string, string][] }] } : {}) }
store.upsertCard(confirm)
const response = deferred<never>()
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const pending = store.cardAction(confirm, 'confirm')
await store.openConversation('b')
response.reject(failure)
await pending
expect(store.getState().cardActionStates[confirm.id]).toBe('unknown')
if (kind === 'hitl') expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBe('unknown')
vi.mocked(api.listCards).mockResolvedValue([confirm])
await store.openConversation('a')
await store.refreshCards('a')
await store.cardAction(confirm, 'confirm')
expect(request).toHaveBeenCalledTimes(1)
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
})
it('does not let an old rejected action clear feedback owned by a newer HITL projection', async () => {
const confirm = { ...card('replacement', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
const old = deferred<never>()
vi.spyOn(api, 'cardAction').mockReturnValue(old.promise)
const first = store.cardAction(confirm, 'confirm')
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
const projected = { ...confirm, body: [{ type: 'kv' as const, kv: [['来源', 'hitl:replacement']] as [string, string][] }] }
store.applyEvent(event(2, 'card.updated', { card: projected }))
const replacement = deferred<Awaited<ReturnType<typeof api.resolveHITL>>>()
vi.spyOn(api, 'resolveHITL').mockReturnValue(replacement.promise)
const second = store.cardAction(projected, 'confirm')
old.reject(new Error('400: rejected'))
await first
expect(store.getState().cardActionStates.replacement).toBe('submitting')
expect(store.getState().cardActionStates['hitl:replacement']).toBe('submitting')
vi.mocked(api.listCards).mockResolvedValue([{ ...projected, status: 'resolved' }])
replacement.resolve({ resolved: true, pivoted: false, pivotText: '' })
await second
expect(store.getState().cardActionStates.replacement).toBeUndefined()
})
it('does not append another conversation receipt to the currently open conversation', async () => {
const foreign = { ...card('other-confirm', 'action_required', 'b'), kind: 'confirm' as const }
store.applyProjectEvent(event(1, 'card.created', { card: foreign }))