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:
@@ -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
@@ -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
@@ -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
@@ -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 }))
|
||||
|
||||
Reference in New Issue
Block a user