Merge pinned master into status bar token metrics
This commit is contained in:
@@ -63,7 +63,25 @@ export const ev = {
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
retry: (
|
||||
seq: number,
|
||||
turn: number,
|
||||
step = 0,
|
||||
retry = 1,
|
||||
maxRetries = 2,
|
||||
delayMs = 500,
|
||||
message = 'temporary transport failure',
|
||||
): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn, step,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry, maxRetries, delayMs,
|
||||
failure: { code: 'TRANSPORT', message },
|
||||
},
|
||||
}),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
|
||||
@@ -140,7 +140,10 @@ export class FakeApiClient implements IApiClient {
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
// The archive-set field defaults at the binding below so list stubs keep
|
||||
// the pre-archive `{ items }` shape; a stub carrying the field wins.
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[]; archivedSessionIds?: never[] }>> =
|
||||
() => Promise.resolve(ok({ items: [] }))
|
||||
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
|
||||
@@ -153,13 +156,22 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceArchiveSession: (payload: unknown) => Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>> =
|
||||
payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => (
|
||||
response.result.ok
|
||||
? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } }
|
||||
: response
|
||||
)) as ReturnType<IApiClient['workspace']['list']>),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
archiveSession: (payload: unknown) =>
|
||||
this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)),
|
||||
}
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
@@ -161,6 +162,214 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '请重试'),
|
||||
ev.stepStart(8, 1),
|
||||
ev.chunkStart(9, 1),
|
||||
ev.chunkText(10, 1, '不完整回复'),
|
||||
ev.stepEnd(11, 1),
|
||||
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error', step: 0,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
|
||||
ev.stepStart(15, 2),
|
||||
ev.assistant(16, 2, '完整回复'),
|
||||
ev.stepEnd(17, 2),
|
||||
ev.turnEnd(18, 2),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 7)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 450,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(7)) feed(event)
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1))
|
||||
feed(ev.chunkText(8, 1, '仍在生成'))
|
||||
const valid = {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary failure' },
|
||||
}
|
||||
const invalid = [
|
||||
{ ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, provider: '' },
|
||||
{ ...valid, policyKey: '' },
|
||||
{ ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, delayMs: -1 },
|
||||
{ ...valid, delayMs: Number.POSITIVE_INFINITY },
|
||||
{ ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
|
||||
{ ...valid, failure: { ...valid.failure, message: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, code: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: '429' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 99 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 429.5 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 600 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: 1 } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: '' } },
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
for (const [index, data] of invalid.entries()) {
|
||||
feed(at(9 + index, { type: 'llm/retry', data }))
|
||||
}
|
||||
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
|
||||
expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
|
||||
expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts complete retry payloads at the producer field boundaries', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: Number.MAX_SAFE_INTEGER,
|
||||
step: Number.MAX_SAFE_INTEGER,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
maxRetries: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: {
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'provider busy',
|
||||
status: 599,
|
||||
providerRetryAfterMs: Number.MIN_VALUE,
|
||||
requestId: 'req-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
feed(at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 3, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'retry forever' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
mode: 'always',
|
||||
retry: 3,
|
||||
})
|
||||
|
||||
feed(at(7, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 4, maxRetries: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
|
||||
},
|
||||
}))
|
||||
feed(at(8, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
|
||||
retry: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unknown mode' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn ends %s',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.retry(7, 1))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
})
|
||||
feed(ev.turnEnd(8, 1, reason))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'cancelled',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -168,7 +377,7 @@ describe('live event path', () => {
|
||||
feed(ev.user(7, '要被打断的'))
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '说到一半'))
|
||||
feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
|
||||
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
const frozen = snapshot.nodes.at(-1)
|
||||
@@ -187,7 +396,7 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().runningCalls).toEqual([])
|
||||
// Second call never resolves: turn/end freezes it as an error card.
|
||||
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
|
||||
feed(ev.turnEnd(10, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(10, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls).toEqual([])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
@@ -529,7 +738,7 @@ describe('remaining branches', () => {
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
|
||||
feed(ev.turnEnd(8, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
|
||||
@@ -543,7 +752,7 @@ describe('remaining branches', () => {
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
|
||||
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
|
||||
feed(ev.turnEnd(9, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(9, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
|
||||
@@ -637,7 +846,7 @@ describe('remaining branches', () => {
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
|
||||
feed(ev.turnEnd(8, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const frozen = session.getSnapshot().nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
|
||||
})
|
||||
|
||||
@@ -183,6 +183,12 @@ describe('WorkspacesService', () => {
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
|
||||
// An archived blank is never reused: no surface can show it, so New
|
||||
// Session mints a fresh one for alpha instead.
|
||||
await workspaces.archiveSession(sid('s-blank'))
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
|
||||
})
|
||||
|
||||
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
|
||||
@@ -285,6 +291,84 @@ describe('WorkspacesService', () => {
|
||||
}))
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
|
||||
{ sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
|
||||
],
|
||||
}) as never)
|
||||
await sessions.refresh()
|
||||
sessions.open(sid('s-open'))
|
||||
|
||||
// Archiving a non-current session installs the unary echo and keeps the selection.
|
||||
await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
|
||||
expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
|
||||
expect(sessions.list.getSnapshot().current).toBe('s-open')
|
||||
|
||||
// Archiving the current session clears it into the New Session view state.
|
||||
api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
|
||||
await workspaces.archiveSession(sid('s-open'))
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
|
||||
expect(sessions.list.getSnapshot().current).toBeUndefined()
|
||||
|
||||
// A Host failure leaves the set and the selection untouched.
|
||||
api.onWorkspaceArchiveSession = () => Promise.resolve(err({
|
||||
code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
|
||||
}))
|
||||
await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
|
||||
|
||||
// The changed frame and the list baseline both re-install the full set.
|
||||
workspaces.handleHostEnvelope({
|
||||
rpcId: 'frame' as never,
|
||||
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
|
||||
} as never)
|
||||
// Frame installs ride the notifier's microtask batch before projecting.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
|
||||
await workspaces.refresh()
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
|
||||
})
|
||||
|
||||
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await sessions.refresh()
|
||||
sessions.open(sid('s-open'))
|
||||
|
||||
// A stale baseline is in flight (older, empty set) when another tab's
|
||||
// archive frame lands: the frame clears the current selection and its
|
||||
// set survives the baseline's later resolution.
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const hydration = workspaces.refresh()
|
||||
workspaces.handleHostEnvelope({
|
||||
rpcId: 'frame' as never,
|
||||
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
|
||||
} as never)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(sessions.list.getSnapshot().current).toBeUndefined()
|
||||
gate.resolve(ok({ items: [], archivedSessionIds: [] }))
|
||||
await hydration
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
|
||||
// The next (fresh) baseline is authoritative again.
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
|
||||
await workspaces.refresh()
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('startInitialSelection', () => {
|
||||
|
||||
Reference in New Issue
Block a user