# Conflicts: # apps/web/tests/expected/agent-preset-selection/header.expected.md # docs/architecture.i18n.yaml # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/persistence-catalog.i18n.yaml # docs/persistence-catalog.md # docs/persistence-catalog.zh.md # docs/persistence-schema.json # docs/subsystems/web-client.i18n.yaml # packages/api/README.i18n.yaml # packages/api/README.md # packages/api/README.zh.md # packages/api/session-controller/README.i18n.yaml # packages/api/session-controller/README.md # packages/api/session-controller/README.zh.md # packages/api/session-controller/src/client/sessions/manager.ts # packages/api/session-controller/src/client/sessions/service.ts # packages/api/session-controller/tests/manager.client.spec.ts # packages/client/locale/tests/language-row.client.spec.tsx # packages/client/ui-chat/tests/chat-view.client.spec.tsx # packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx # packages/client/ui-conversation/tests/conversation-registry.client.spec.ts # packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx # packages/client/ui-conversation/tests/input-bar.client.spec.tsx # packages/client/ui-conversation/tests/input-matrix.client.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx # packages/client/ui-conversation/tests/skeleton.client.spec.tsx # packages/client/ui-deliverables/tests/deliverables.client.spec.tsx # packages/client/ui-jobs/tests/job-list-action.client.spec.tsx # packages/client/ui-layout/tests/app-frame.client.spec.tsx # packages/client/ui-layout/tests/document-title.client.spec.tsx # packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-session/tests/ui-session.client.spec.ts # packages/client/ui-settings-general/tests/settings-root.client.spec.tsx # packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx # packages/client/ui-subagent/README.i18n.yaml # packages/client/ui-subagent/README.md # packages/client/ui-subagent/README.zh.md # packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx # packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx # packages/client/ui-theme/tests/appearance-row.client.spec.tsx # packages/client/ui-theme/tests/font-size-row.client.spec.tsx # packages/client/ui-tool/tests/coverage-tails.client.spec.tsx # packages/client/ui-tool/tests/diff-card.client.spec.tsx # packages/client/ui-tool/tests/image-card.client.spec.tsx # packages/client/ui-tool/tests/read-card.client.spec.tsx # packages/client/ui-tool/tests/terminal-card.client.spec.tsx # packages/client/ui-trajectory/tests/views.client.spec.tsx # packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx # packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx # packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx # packages/client/ui-workspace/tests/apply.client.spec.ts # packages/client/ui-workspace/tests/tree.client.spec.ts # packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx # packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx # packages/client/ui-workspace/tests/workspaces-service.client.spec.ts # packages/extensions/cordis-client-runner/src/client/slot-catalog.ts # packages/jobs/tool-jobs/src/index.ts # packages/shell/tool-bash/README.i18n.yaml # packages/test-support/client-runtime/src/sessions.ts
174 lines
7.0 KiB
TypeScript
174 lines
7.0 KiB
TypeScript
/** Inbox projection delivery and queue-operation transport. */
|
|
|
|
import { describe, expect, onTestFinished } from 'vitest'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
|
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import { SessionManager } from '../src/client/sessions/manager.ts'
|
|
import { ok } from '@deepseek-ai/dsh-remote-mock'
|
|
import { createClientTest, type ClientTestFixtures, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
|
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
|
|
|
|
const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
|
|
|
|
function makeManager(remote: ClientTestFixtures['remote']): SessionManager {
|
|
const manager = new SessionManager(remote as unknown as SessionRemotes)
|
|
onTestFinished(() => manager.dispose())
|
|
return manager
|
|
}
|
|
|
|
const SID = 'fk-q1' as SessionId
|
|
const text = (value: string) => [{ type: 'text' as const, text: value }]
|
|
|
|
let nextSeq = 1
|
|
|
|
function message(label: string, body: string) {
|
|
return createUserMessage({
|
|
content: text(body),
|
|
source: { kind: 'user', rpcId: `rpc-${label}` } as never,
|
|
})
|
|
}
|
|
|
|
function inboxFrame(value: InboxState): Extract<SessionControlFrame, { type: 'projection' }> {
|
|
return {
|
|
type: 'projection',
|
|
sessionId: SID,
|
|
key: 'inbox',
|
|
seq: nextSeq++,
|
|
value: value as never,
|
|
}
|
|
}
|
|
|
|
describe('Inbox projection intake', () => {
|
|
it('stores the complete Agent-owned value without adding queue state to the Session snapshot', ({ remote }) => {
|
|
const manager = makeManager(remote)
|
|
const queued = message('queued', 'later')
|
|
const steering = message('steering', 'now')
|
|
const value = { 'next-turn': [queued], 'next-step': [steering] }
|
|
|
|
manager.handleControlFrame(inboxFrame(value))
|
|
const session = manager.get(SID)
|
|
|
|
expect(session.projections.faceOf('inbox').getSnapshot()).toEqual(value)
|
|
expect(session.getSnapshot()).not.toHaveProperty('queue')
|
|
})
|
|
|
|
it.for(['included', 'omitted'] as const)(
|
|
'keeps a newer list Inbox when a delayed control baseline has the key %s',
|
|
async (key, { remote }) => {
|
|
const list = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
|
|
remote.session.list.mockReturnValue(list.promise)
|
|
const manager = makeManager(remote)
|
|
const empty = { 'next-turn': [], 'next-step': [] }
|
|
const stale = { ...empty, 'next-turn': [message('removed', 'already removed')] }
|
|
const result = ok({ items: [{
|
|
sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false,
|
|
// The Session is attached: the Host's live registry served the block.
|
|
projections: { kind: 'sequenced' as const, asOfSeq: 21, values: { inbox: empty } },
|
|
}] })
|
|
let refreshed: Promise<void> | undefined
|
|
|
|
try {
|
|
manager.handleConnected()
|
|
refreshed = manager.refreshList()
|
|
list.resolve(result)
|
|
await refreshed
|
|
const face = manager.get(SID).projections.faceOf('inbox')
|
|
expect(face.getSnapshot()).toEqual(empty)
|
|
|
|
manager.handleControlFrame({
|
|
type: 'baseline',
|
|
value: { projections: { [SID]: {
|
|
asOfSeq: 20, values: key === 'included' ? { inbox: stale } : {},
|
|
} } },
|
|
})
|
|
|
|
expect(face.getSnapshot()).toEqual(empty)
|
|
} finally {
|
|
list.resolve(result)
|
|
await refreshed
|
|
await manager.dispose()
|
|
}
|
|
},
|
|
)
|
|
|
|
it.for(['control-first', 'list-first'] as const)(
|
|
'replaces cold Session Inbox values across Host generations (%s)',
|
|
async (order, { remote }) => {
|
|
const list = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
|
|
remote.session.list.mockReturnValue(list.promise)
|
|
const manager = makeManager(remote)
|
|
const hiddenSessionId = 'cold-hidden-inbox' as SessionId
|
|
const ghost = message('ghost', 'acceptance was not persisted')
|
|
const pending = message('pending', 'claim was not persisted')
|
|
const empty = { 'next-turn': [], 'next-step': [] }
|
|
const restored = { 'next-turn': [pending], 'next-step': [] }
|
|
manager.handleControlFrame({ ...inboxFrame({ ...empty, 'next-turn': [ghost] }), seq: 20 })
|
|
manager.handleControlFrame({ ...inboxFrame(empty), sessionId: hiddenSessionId, seq: 20 })
|
|
const face = manager.get(SID).projections.faceOf('inbox')
|
|
const baseline = { type: 'baseline', value: { projections: {} } } as const
|
|
const result = ok({ items: [
|
|
{ sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false,
|
|
projections: { kind: 'cached' as const, asOfSeq: 1, values: { inbox: empty } } },
|
|
{ sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false, agentAvailable: false,
|
|
projections: { kind: 'cached' as const, asOfSeq: 1, values: { inbox: restored } } },
|
|
] })
|
|
let refreshed: Promise<void> | undefined
|
|
|
|
try {
|
|
manager.handleConnected()
|
|
refreshed = manager.refreshList()
|
|
expect(face.getSnapshot()).toBeUndefined()
|
|
if (order === 'control-first') manager.handleControlFrame(baseline)
|
|
list.resolve(result)
|
|
await refreshed
|
|
if (order === 'list-first') manager.handleControlFrame(baseline)
|
|
|
|
expect(manager.get(SID).projections.faceOf('inbox')).toBe(face)
|
|
expect(face.getSnapshot()).toEqual(empty)
|
|
expect(manager.get(hiddenSessionId).projections.faceOf('inbox').getSnapshot()).toEqual(restored)
|
|
} finally {
|
|
list.resolve(result)
|
|
await refreshed
|
|
await manager.dispose()
|
|
}
|
|
},
|
|
)
|
|
|
|
it('retains only the highest-seq value received before Session materialization', ({ remote }) => {
|
|
const manager = makeManager(remote)
|
|
manager.handleControlFrame(inboxFrame({
|
|
'next-turn': [message('old', 'old')],
|
|
'next-step': [],
|
|
}))
|
|
const latest = {
|
|
'next-turn': [message('latest', 'latest')],
|
|
'next-step': [],
|
|
}
|
|
manager.handleControlFrame(inboxFrame(latest))
|
|
|
|
expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toEqual(latest)
|
|
})
|
|
})
|
|
|
|
describe('queue operation transport', () => {
|
|
it('does not mutate the Inbox projection before the Host publishes its committed value', async ({ remote }) => {
|
|
remote.session.updateQueue.mockResolvedValue(ok({ accepted: true }))
|
|
const manager = makeManager(remote)
|
|
const pending = message('pending', 'before')
|
|
const initial = { 'next-turn': [pending], 'next-step': [] }
|
|
manager.handleControlFrame(inboxFrame(initial))
|
|
const session = manager.get(SID)
|
|
|
|
await expect(session.updateQueue(pending.id, { kind: 'edit', content: text('after') }))
|
|
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
|
expect(remote.session.updateQueue).toHaveBeenCalledExactlyOnceWith({
|
|
sessionId: SID,
|
|
itemId: pending.id,
|
|
action: { kind: 'edit', content: text('after') },
|
|
})
|
|
expect(session.projections.faceOf('inbox').getSnapshot()).toBe(initial)
|
|
})
|
|
})
|