Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
@@ -22,16 +23,22 @@ interface Bench {
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
BIN
packages/client/runtime/tests/context-provenance.spec.ts
Normal file
BIN
packages/client/runtime/tests/context-provenance.spec.ts
Normal file
Binary file not shown.
@@ -12,7 +12,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
|
||||
export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
at(seq, { type: 'turn/start', data: { turn } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
@@ -82,9 +82,16 @@ export const ev = {
|
||||
},
|
||||
}),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
at(seq, { type: 'turn/end', data: {
|
||||
turn,
|
||||
reason: reason === 'completed'
|
||||
? { kind: 'completed' }
|
||||
: { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
|
||||
} }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
/** A compaction's log-only `compact/summary` provenance record. */
|
||||
|
||||
@@ -73,6 +73,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -135,6 +136,19 @@ export class FakeApiClient implements IApiClient {
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
|
||||
= () => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
@@ -206,7 +220,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
@@ -221,6 +236,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -1,13 +1,89 @@
|
||||
import { createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
|
||||
|
||||
describe('projectConversationHistory', () => {
|
||||
it('names an injected context node from its durable source, like the live adapter', () => {
|
||||
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
||||
// the provenance projection is pinned on both sides independently.
|
||||
const injected = at(0, {
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
|
||||
// A plugin source, because the client program does not see the host
|
||||
// packages that merge richer source kinds; those arms are pinned in
|
||||
// context-provenance.spec.ts.
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
||||
}),
|
||||
})
|
||||
const { contexts } = projectConversationHistory([{ event: injected }])
|
||||
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
form: 'catalog',
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects next-step human input as durable steering', () => {
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'change course' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const events = [
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
]
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes).toMatchObject([{
|
||||
kind: 'steering', messageId: steering.id, seq: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
|
||||
const baseSeq = 400_000
|
||||
const events = [
|
||||
ev.user(baseSeq, 'loaded tail'),
|
||||
at(baseSeq + 1, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
|
||||
sourceEventSeqs: [baseSeq],
|
||||
data: {
|
||||
turn: 80,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'tail summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
|
||||
expect(projection.contexts.map(context => ({
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ originSeq: undefined, nodes: [baseSeq] },
|
||||
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
@@ -91,4 +167,35 @@ describe('projectConversationHistory', () => {
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
ev.stepStart(1, 1, 0),
|
||||
ev.chunkStart(2, 1),
|
||||
ev.chunkText(3, 1, ''),
|
||||
ev.chunkText(4, 1, 'first'),
|
||||
ev.chunkText(5, 1, ' discarded'),
|
||||
at(6, { type: 'assistant/chunk', data: {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
|
||||
} }),
|
||||
ev.assistant(7, 1, 'first discarded'),
|
||||
ev.compactSummary(8, 'summary', 0, 7),
|
||||
ev.compactCheckpoint(9, 8, 0, 7),
|
||||
ev.stepStart(10, 2, 0),
|
||||
ev.chunkStart(11, 2),
|
||||
ev.chunkText(12, 2, 'interrupted'),
|
||||
ev.turnEnd(13, 2, 'aborted'),
|
||||
]
|
||||
const raw = events.map(event => ({ event }))
|
||||
const compacted = compactHistoryInspectionEntries(raw)
|
||||
|
||||
expect(compacted.map(entry => entry.event.seq)).toEqual([
|
||||
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
])
|
||||
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
|
||||
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('projects the completion-reminder set into rows (absent = false)', () => {
|
||||
const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId]))
|
||||
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
|
||||
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
|
||||
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,13 @@ import { entries, plainTurn } from './event-script.ts'
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
const S2 = 'fk-m2' as SessionId
|
||||
|
||||
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
|
||||
type SummaryOver = Partial<{
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
blank: boolean
|
||||
parentSessionId: SessionId
|
||||
origin: 'subagent'
|
||||
}>
|
||||
|
||||
function summary(sessionId: SessionId, over: SummaryOver = {}) {
|
||||
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
|
||||
@@ -34,6 +40,7 @@ describe('instances', () => {
|
||||
const manager = new SessionManager(api)
|
||||
// Uninstantiated: approval buffers, plain session/event drops.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
|
||||
@@ -41,16 +48,26 @@ describe('instances', () => {
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
|
||||
it('retains every live answerable request and compacts resolutions before instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
}
|
||||
const pending = manager.get(S1).getSnapshot().pending
|
||||
expect(pending).toHaveLength(32)
|
||||
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: `r${i}` as never,
|
||||
payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' },
|
||||
})
|
||||
}
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
expect(manager.get(S1).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('drops buffered answerable requests on session removal', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
// Removed session: buffered frames must not replay on a future instantiation.
|
||||
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
|
||||
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
@@ -272,6 +289,402 @@ describe('host frame routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent catalogs', () => {
|
||||
it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
summary(S1),
|
||||
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
// Clicking the same child through an ordinary list-selection path must not
|
||||
// erase the catalog-derived address and fall back to session.* transport.
|
||||
manager.select(S2)
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await manager.get(S2).open()
|
||||
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
const listCalls = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-complete' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: false },
|
||||
})
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
|
||||
kind: 'child', id: S2, activity: 'inactive',
|
||||
})
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
|
||||
origin: 'subagent', parentSessionId: S1, running: false,
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({
|
||||
removed: false,
|
||||
subagent: {
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('refetches debounced membership only while the parent catalog is open', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
const baseline = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-again' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
|
||||
manager.setSubagentCatalogOpen(S1, false)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-closed' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'nested-subagent' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
|
||||
parentSessionId: S1, origin: 'subagent', blank: false,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'ordinary-fork' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-fork' as SessionId,
|
||||
parentSessionId: S2, blank: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, hasChildren: true },
|
||||
{ kind: 'child', id: S2, hasChildren: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'nested-subagent' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
|
||||
parentSessionId: S1, origin: 'subagent', blank: false,
|
||||
},
|
||||
})
|
||||
response.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, hasChildren: true },
|
||||
])
|
||||
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await manager.refreshSubagents(root)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, hasChildren: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('replays status frames over an older in-flight catalog response', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-stopped' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S1, running: false },
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-started' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: true },
|
||||
})
|
||||
response.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
|
||||
activity: 'running', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'started',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, activity: 'inactive' },
|
||||
{ kind: 'child', id: S2, activity: 'running' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks a detached catalog child inactive without requiring a selected address', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S2, activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
expect(manager.refreshSubagents(root)).toBe(refresh)
|
||||
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
first.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
await refresh
|
||||
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, root)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
// A membership frame arrives while the pull is in flight; the debounced
|
||||
// refresh it schedules fires 50ms later and is coalesced into the pull —
|
||||
// which was requested before the new child existed. The stale mark must
|
||||
// queue one trailing pull carrying the change.
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
api.onSubagentList = () => second.promise
|
||||
first.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
// The trailing pull is already in flight (kicked synchronously in finally).
|
||||
second.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await second.promise
|
||||
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(2)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, label: 'older' },
|
||||
{ kind: 'child', id: S2, label: 'new child' },
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const child = () => ({
|
||||
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
|
||||
activity: 'inactive' as const, hasChildren: false,
|
||||
})
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await refresh
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
// The removal lands while a second pull is in flight: the invalidation
|
||||
// must survive the pre-removal ok response, so one trailing pull runs.
|
||||
const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => mid.promise
|
||||
const midRefresh = manager.refreshSubagents(root)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'parent-removed-mid-pull' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: root },
|
||||
})
|
||||
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => trailing.promise
|
||||
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await midRefresh
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
|
||||
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
|
||||
state: 'error',
|
||||
parentAvailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
const rootCalls = api.callsOf('subagent.list')
|
||||
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
|
||||
expect(rootCalls).toHaveLength(3)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
})
|
||||
|
||||
it('invalidates catalog availability when the owning parent is removed', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(root)
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'parent-removed' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: root },
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
@@ -409,9 +822,17 @@ describe('remaining branches', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'h2' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', blank: true, sessionId: S2,
|
||||
parentSessionId: S1, origin: 'subagent',
|
||||
},
|
||||
})
|
||||
const items = manager.getListSnapshot().items
|
||||
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
|
||||
expect(items.find(e => e.sessionId === S2)).toMatchObject({
|
||||
parentSessionId: S1, origin: 'subagent', depth: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -435,50 +856,119 @@ describe('connected generation', () => {
|
||||
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads the durable parent address for a restored child selection', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const address = {
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
|
||||
}
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
|
||||
})
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual(address)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
describe('pending-interaction list status', () => {
|
||||
it('tracks approval requests through replay and resolution without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q2' as never,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: S1,
|
||||
questions: [{
|
||||
id: 'plan', question: 'Approve?', detail: '# Plan',
|
||||
options: [{ label: 'Approve' }, { label: 'Refuse' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing detail', {}],
|
||||
['multi-select', { detail: '# Plan', multiSelect: true }],
|
||||
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
|
||||
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
|
||||
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q-plan' as never,
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: S1,
|
||||
questions: [{
|
||||
id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
...over,
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
})
|
||||
|
||||
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
it('drops stale status at generation death before replay re-adds live interactions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
@@ -495,3 +985,128 @@ describe('waiting-approval list bit', () => {
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('completed reminder', () => {
|
||||
const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'host/session-status' as const, sessionId, running },
|
||||
})
|
||||
const added = (rpcId: string, sessionId: SessionId) => ({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'host/session-added' as const, sessionId, blank: false },
|
||||
})
|
||||
const entry = (manager: SessionManager, sessionId: SessionId) =>
|
||||
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
|
||||
|
||||
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
// Opening the session consumes the reminder.
|
||||
manager.select(S2)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
})
|
||||
|
||||
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S2)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
|
||||
// Switch away; a fresh run completing again arms the reminder.
|
||||
manager.select(S1)
|
||||
manager.handleHostEnvelope(status('s3', S2, true))
|
||||
manager.handleHostEnvelope(status('s4', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
// The user starts a new run without opening the session: running wins.
|
||||
manager.handleHostEnvelope(status('s3', S2, true))
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
manager.handleHostEnvelope(status('s4', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('session-removed drops the reminder and a re-add starts clean', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
|
||||
manager.handleHostEnvelope(added('h3', S2))
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
})
|
||||
|
||||
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
|
||||
await manager.refreshList()
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('never arms for sessions already idle at first observation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
|
||||
await manager.refreshList()
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
})
|
||||
|
||||
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshList()
|
||||
// The session finishes while the first pull is still in flight; the pull
|
||||
// response recorded it as running at pull time.
|
||||
manager.handleHostEnvelope(status('s-mid', S2, false))
|
||||
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
await refresh
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshList()
|
||||
// The unknown session starts and finishes while the first pull is in
|
||||
// flight; the pull-time baseline recorded it idle, so the running→idle
|
||||
// edge lives entirely inside the replayed mutations.
|
||||
manager.handleHostEnvelope(status('s-start', S2, true))
|
||||
manager.handleHostEnvelope(status('s-finish', S2, false))
|
||||
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
await refresh
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
|
||||
* laziness, synchronous notifyNow, and unsubscribe.
|
||||
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
|
||||
* no-listener laziness, synchronous notifyNow, and unsubscribe.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Notifier } from '../src/client/sessions/notifier.ts'
|
||||
|
||||
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Notifier', () => {
|
||||
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
|
||||
const order: string[] = []
|
||||
@@ -60,6 +64,57 @@ describe('Notifier', () => {
|
||||
expect(rebuilds).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses frame-dirty changes into one cumulative frame publication', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const order: string[] = []
|
||||
const notifier = new Notifier(() => order.push('rebuild'))
|
||||
notifier.subscribe(() => order.push('notify'))
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
|
||||
expect(order).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
frames.shift()!(0)
|
||||
expect(order).toEqual(['rebuild', 'notify'])
|
||||
})
|
||||
|
||||
it('lets a structural microtask publication supersede a pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markDirty()
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching when animation frames are unavailable', async () => {
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
expect(notifications).toBe(0)
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('unsubscribed listeners stop receiving notifications', async () => {
|
||||
let calls = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
|
||||
@@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
|
||||
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
const empty = store.values()
|
||||
expect(store.values()).toBe(empty)
|
||||
store.apply('test/marks', { marks: ['a'] }, 1)
|
||||
const populated = store.values()
|
||||
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(populated).not.toBe(empty)
|
||||
expect(store.values()).toBe(populated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
@@ -167,6 +178,36 @@ describe('manager frame routing', () => {
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects every retained value into list rows with stable snapshot identity', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
projections: {
|
||||
asOfSeq: 2,
|
||||
values: { 'test/marks': { marks: ['baseline'] } },
|
||||
},
|
||||
}],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
const baseline = manager.getListSnapshot().items[0]?.projectionValues
|
||||
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: {
|
||||
type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
|
||||
value: { marks: ['live'] }, seq: 3,
|
||||
} as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues)
|
||||
.toEqual({ 'test/marks': { marks: ['live'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
InboxItemId, MuxFrame, RpcId, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
@@ -16,12 +15,14 @@ import { FakeApiClient } from './fake-api.ts'
|
||||
const SID = 'fk-q1' as SessionId
|
||||
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
const iid = (id: string): InboxItemId => id as InboxItemId
|
||||
const iid = (id: string): MessageId => id as MessageId
|
||||
|
||||
interface QueueFixture {
|
||||
id: string
|
||||
body: string
|
||||
content?: ContentBlock[]
|
||||
placement?: 'queued' | 'steering'
|
||||
message?: UserMessage
|
||||
}
|
||||
|
||||
/** Build one authoritative queue snapshot. */
|
||||
@@ -31,7 +32,8 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
|
||||
sessionId: SID,
|
||||
items: items.map(item => ({
|
||||
id: iid(item.id),
|
||||
message: createUserMessage({
|
||||
placement: item.placement ?? 'queued',
|
||||
message: item.message ?? createUserMessage({
|
||||
content: item.content ?? text(item.body),
|
||||
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
|
||||
}),
|
||||
@@ -49,8 +51,14 @@ describe('queue snapshot intake', () => {
|
||||
session.handleMuxEnvelope(rid('env-1'), queueFrame([
|
||||
{ id: 'q-1', body: '第一条 排队\n消息' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-1', placement: 'queued',
|
||||
content: [{ type: 'text', text: '第一条 排队\n消息' }],
|
||||
preview: '第一条 排队 消息', text: '第一条 排队\n消息',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -61,8 +69,14 @@ describe('queue snapshot intake', () => {
|
||||
body: '',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
}]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-image', preview: 'hi [image]', text: null },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-image', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
|
||||
preview: 'hi [image]', text: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -85,8 +99,14 @@ describe('queue snapshot intake', () => {
|
||||
session.handleMuxEnvelope(rid('env-5'), queueFrame([
|
||||
{ id: 'q-2', body: 'two edited' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-2', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'two edited' }],
|
||||
preview: 'two edited', text: 'two edited',
|
||||
},
|
||||
])
|
||||
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
@@ -99,6 +119,81 @@ describe('queue snapshot intake', () => {
|
||||
session.handleAgentError('unrelated')
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
|
||||
it('retains steering placement and complete content in the same authoritative snapshot', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-steering'), queueFrame([
|
||||
{ id: 'q-next', body: 'later' },
|
||||
{ id: 's-now', body: 'interrupt now', placement: 'steering' },
|
||||
]))
|
||||
|
||||
expect(session.getSnapshot().queue.map(item => ({
|
||||
id: item.id, placement: item.placement, content: item.content,
|
||||
}))).toEqual([
|
||||
{ id: 'q-next', placement: 'queued', content: text('later') },
|
||||
{ id: 's-now', placement: 'steering', content: text('interrupt now') },
|
||||
])
|
||||
})
|
||||
|
||||
it('hands off exactly one current occurrence when live steering becomes durable', async () => {
|
||||
const session = makeSession()
|
||||
await session.open()
|
||||
const message = createUserMessage({
|
||||
content: text('same message'),
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
|
||||
{ id: 's-first', body: '', placement: 'steering', message },
|
||||
{ id: 's-second', body: '', placement: 'steering', message },
|
||||
]))
|
||||
const durable = {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: message,
|
||||
} as SessionEvent
|
||||
|
||||
session.handleMuxEnvelope(rid('env-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
|
||||
|
||||
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
|
||||
{ id: 's-later', body: '', placement: 'steering', message },
|
||||
]))
|
||||
session.handleMuxEnvelope(rid('env-replayed-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
|
||||
})
|
||||
|
||||
it('hands off live steering when the agent claims it as a user message', async () => {
|
||||
const session = makeSession()
|
||||
await session.open()
|
||||
const message = createUserMessage({
|
||||
content: text('claimed steering'),
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.handleMuxEnvelope(rid('env-claimed'), queueFrame([
|
||||
{ id: 's-claimed', body: '', placement: 'steering', message },
|
||||
]))
|
||||
|
||||
session.handleMuxEnvelope(rid('env-user-message'), {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: message,
|
||||
},
|
||||
})
|
||||
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue operation transport', () => {
|
||||
@@ -110,11 +205,20 @@ describe('queue operation transport', () => {
|
||||
|
||||
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
}])
|
||||
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([
|
||||
{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
},
|
||||
{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'steer' },
|
||||
},
|
||||
])
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,6 +85,56 @@ describe('inspectRequests', () => {
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('does not promote a truncated resume or change header to the initial prompt', () => {
|
||||
for (const reason of ['resume', 'change'] as const) {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(10, 'step/start', { turn: 3, step: 1 }),
|
||||
at(11, 'request/header', {
|
||||
reason,
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'tail-window prompt',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
prompt: { system: 'tail-window prompt' },
|
||||
})
|
||||
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a prompt change once the preceding header is loaded', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'before',
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 1, step: 2 }),
|
||||
at(3, 'request/header', {
|
||||
reason: 'change',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'after',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[1]).toMatchObject({
|
||||
promptChange: {
|
||||
seq: 3,
|
||||
kind: 'system',
|
||||
previous: { system: 'before' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
@@ -225,20 +275,15 @@ describe('inspectRequests', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||
at(3, 'turn/end', {
|
||||
turn: 2,
|
||||
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
|
||||
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
}),
|
||||
]))
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
it('loads the tail first and prepends older pages on demand', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
@@ -30,10 +30,21 @@ describe('SessionHistorySource', () => {
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(1)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
expect(source.getSnapshot().baseSeq).toBe(12)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([13, 15])
|
||||
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().baseSeq).toBe(0)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
@@ -42,7 +53,7 @@ describe('SessionHistorySource', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
@@ -60,7 +71,7 @@ describe('SessionHistorySource', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
@@ -132,13 +143,14 @@ describe('SessionHistorySource', () => {
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
it('finishes an already started older page after consumer cancellation', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
@@ -151,7 +163,8 @@ describe('SessionHistorySource', () => {
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await source.loadTail(controller.signal)
|
||||
const complete = source.loadOlder(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
@@ -159,7 +172,7 @@ describe('SessionHistorySource', () => {
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
expect(await complete).toBe(true)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* enough.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, 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'
|
||||
@@ -18,6 +18,11 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
@@ -41,6 +46,11 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.turnTimings.get(3)).toEqual({
|
||||
startTime: 1_700_000_000_010,
|
||||
endTime: 1_700_000_000_015,
|
||||
})
|
||||
expect(snapshot.turnEnds.get(3)).toBe(15)
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -162,7 +172,41 @@ 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 () => {
|
||||
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const { session } = await opened()
|
||||
const published: Array<string | null> = []
|
||||
session.subscribe(() => {
|
||||
const block = session.getSnapshot().partial?.blocks[0]
|
||||
published.push(block?.kind === 'text' ? block.text : null)
|
||||
})
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
|
||||
feed(ev.chunkStart(6, 1))
|
||||
feed(ev.chunkText(7, 1, '累'))
|
||||
feed(ev.chunkText(8, 1, '计'))
|
||||
expect(published).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计'])
|
||||
|
||||
feed(ev.chunkText(9, 1, '完成'))
|
||||
feed(ev.assistant(10, 1, '累计完成'))
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual(['累计', null])
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计', null])
|
||||
})
|
||||
|
||||
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
@@ -171,25 +215,13 @@ describe('live event path', () => {
|
||||
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),
|
||||
ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
ev.chunkStart(12, 1),
|
||||
ev.assistant(13, 1, '完整回复'),
|
||||
ev.stepEnd(14, 1),
|
||||
ev.turnEnd(15, 1),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 7)) feed(event)
|
||||
for (const event of retryTurn.slice(0, 6)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
@@ -208,17 +240,27 @@ describe('live event path', () => {
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(7)) feed(event)
|
||||
for (const event of retryTurn.slice(6)) feed(event)
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
const retryStart = retryTurn.find(event => event.type === 'turn/start')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({
|
||||
startTime: retryStart.time,
|
||||
endTime: retryEnd.time,
|
||||
})
|
||||
|
||||
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().turnTimings).toEqual(snapshot.turnTimings)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
@@ -230,35 +272,33 @@ describe('live event path', () => {
|
||||
const failedTurns = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '鉴权失败'),
|
||||
at(8, {
|
||||
ev.stepStart(8, 1),
|
||||
at(9, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 0,
|
||||
failure: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
data: { turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ev.turnStart(9, 2),
|
||||
ev.user(10, '内部失败'),
|
||||
at(11, {
|
||||
ev.turnStart(10, 2),
|
||||
ev.user(11, '内部失败'),
|
||||
ev.stepStart(12, 2, 1),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
|
||||
data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
|
||||
}),
|
||||
]
|
||||
for (const event of failedTurns) feed(event)
|
||||
|
||||
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
|
||||
expect(errors).toMatchObject([
|
||||
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
|
||||
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
// Every failed turn carries a structured failure; unstructured errors
|
||||
// flatten to the UNKNOWN code.
|
||||
{ seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' },
|
||||
])
|
||||
expect('code' in errors[1]!).toBe(false)
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
||||
@@ -395,7 +435,7 @@ describe('live event path', () => {
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn ends %s',
|
||||
'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
@@ -415,6 +455,24 @@ describe('live event path', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('marks a scheduled retry as started when its failed turn ends with an error', 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.retry(7, 1))
|
||||
feed(at(8, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
|
||||
}))
|
||||
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'started',
|
||||
})
|
||||
})
|
||||
|
||||
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 }) }
|
||||
@@ -425,6 +483,7 @@ describe('live event path', () => {
|
||||
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.turnEnds.get(1)).toBe(10)
|
||||
const frozen = snapshot.nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
|
||||
// Ordered inside the flow: after the user message (seq 7), before any later turn.
|
||||
@@ -580,6 +639,51 @@ describe('paging', () => {
|
||||
})
|
||||
|
||||
describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{
|
||||
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
expect(session.getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
|
||||
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([])
|
||||
})
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// The blank → engaging edge fires before the RPC settles: the first-send
|
||||
@@ -1184,6 +1288,8 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.turnTimings).toBe(before.turnTimings)
|
||||
expect(after.turnEnds).toBe(before.turnEnds)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* with derived titles), the migrated current-selection account (open
|
||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
||||
* deferral — the stage follows list.current), binding identity, ancestry
|
||||
* walk, create.
|
||||
* deferral — the stage follows list.current), binding identity, breadcrumb
|
||||
* projection, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -28,7 +28,14 @@ function bench(): Bench {
|
||||
}
|
||||
|
||||
/** Refresh the manager list from programmable rows and flush the microtask batch. */
|
||||
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
|
||||
type FeedRow = {
|
||||
id: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
origin?: 'subagent'
|
||||
running?: boolean
|
||||
blank?: boolean
|
||||
}
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
@@ -36,6 +43,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
...(r.origin !== undefined ? { origin: r.origin } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
await b.svc.refresh()
|
||||
@@ -51,12 +59,14 @@ describe('list store projection', () => {
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
{ id: 's2', parentId: 's1', running: true },
|
||||
{ id: 's2', parentId: 's1', origin: 'subagent', running: true },
|
||||
])
|
||||
const state = b.svc.list.getSnapshot()
|
||||
expect(state.ids).toEqual(['s1', 's2'])
|
||||
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({
|
||||
displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true,
|
||||
})
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -351,18 +361,89 @@ describe('slot-store scope prune hook', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ancestry', () => {
|
||||
it('walks parentId links root-first including self; broken links stop the walk', async () => {
|
||||
describe('catalog-addressed navigation', () => {
|
||||
it('uses catalog labels for a listed addressed route', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [
|
||||
{ id: 'root', cwd: '/w/app' },
|
||||
{ id: 'mid', parentId: 'root' },
|
||||
{ id: 'leaf', parentId: 'mid' },
|
||||
{ id: 'orphan', parentId: 'ghost' },
|
||||
{ id: 'root' },
|
||||
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
|
||||
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
|
||||
])
|
||||
expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
|
||||
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
|
||||
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
await b.svc.refreshSubagents(sid('child'))
|
||||
b.svc.openSubagent({
|
||||
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
||||
})
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
|
||||
})
|
||||
|
||||
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [{ id: 'root' }])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
await b.svc.refreshSubagents(sid('child'))
|
||||
b.svc.openSubagent({
|
||||
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
||||
})
|
||||
|
||||
const list = b.svc.list.getSnapshot()
|
||||
expect(list.ids).toEqual([sid('root')])
|
||||
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
|
||||
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
|
||||
expect(b.svc.binding(sid('child'))).toBeUndefined()
|
||||
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
|
||||
|
||||
b.svc.open(sid('child'))
|
||||
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
|
||||
expect(b.svc.subagentAddress(sid('child'))).toEqual({
|
||||
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -455,6 +536,17 @@ describe('fork', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('floors a fractional anchor to the real event seq the wire accepts', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
|
||||
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
|
||||
|
||||
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
|
||||
})
|
||||
|
||||
it('does not rename without the title policy or a durable source title', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'source', cwd: '/work' }])
|
||||
|
||||
@@ -29,6 +29,7 @@ const C: FC<object> = () => null
|
||||
*/
|
||||
interface ErasedService {
|
||||
register(options: object, component: unknown): () => void
|
||||
inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
|
||||
install(renderer: object): void
|
||||
renderSlot(key: string, owner: object): unknown
|
||||
}
|
||||
@@ -166,6 +167,266 @@ describe('load-time validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('declaration injection', () => {
|
||||
it('activates immediately and ignores ordinary entry mutations', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
|
||||
const dispose = bench.erased.inject('t.rows', setup)
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
dispose()
|
||||
expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
|
||||
})
|
||||
|
||||
it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
|
||||
const bench = await boot()
|
||||
const cleanup = vi.fn()
|
||||
const setup = vi.fn(() => {
|
||||
const unregister = bench.erased.register({ name: 't.host' }, C)
|
||||
return () => { unregister(); cleanup() }
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
disposeFrame()
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
|
||||
const bench = await boot()
|
||||
const firstFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const cleanup = vi.fn()
|
||||
const setup = vi.fn(() => {
|
||||
const unregister = bench.erased.register({ name: 't.host' }, C)
|
||||
return () => { unregister(); cleanup() }
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
firstFrame()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(setup).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
|
||||
const active = await boot()
|
||||
active.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const activeFiber = active.ctx.plugin({
|
||||
name: 'active-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
|
||||
})
|
||||
await activeFiber.await()
|
||||
expect(active.svc.entries('t.host')).toHaveLength(1)
|
||||
await activeFiber.dispose()
|
||||
expect(active.svc.entries('t.host')).toHaveLength(0)
|
||||
|
||||
const waiting = await boot()
|
||||
const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
|
||||
const waitingFiber = waiting.ctx.plugin({
|
||||
name: 'waiting-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
|
||||
})
|
||||
await waitingFiber.await()
|
||||
await waitingFiber.dispose()
|
||||
waiting.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back earlier yielded registrations when generator setup fails', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.rows': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.host' }, C)
|
||||
expect(() => bench.erased.inject('t.rows', function* () {
|
||||
yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
|
||||
yield bench.erased.register({ name: 't.host' }, C)
|
||||
})).toThrow(/already has a registration/)
|
||||
expect(bench.svc.entries('t.rows')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
|
||||
const bench = await boot()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const setup = vi.fn(function* () {
|
||||
yield bench.erased.register({ name: 't.host' }, C)
|
||||
throw null
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', later)
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]).toBeInstanceOf(Error)
|
||||
expect(String(failures[0])).toContain('null')
|
||||
expect(later).toHaveBeenCalledOnce()
|
||||
disposeFrame()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips a stopped controller retained by the current declaration snapshot', async () => {
|
||||
const bench = await boot()
|
||||
let stopLater = (): void => {}
|
||||
const first = vi.fn(() => {
|
||||
stopLater()
|
||||
return () => undefined
|
||||
})
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', first)
|
||||
stopLater = bench.erased.inject('t.host', later)
|
||||
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
expect(later).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
|
||||
const bench = await boot()
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
let disposeReplacement = (): void => {}
|
||||
let replaced = false
|
||||
const first = vi.fn(() => () => {
|
||||
if (replaced) return
|
||||
replaced = true
|
||||
disposeReplacement = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
})
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', first)
|
||||
bench.erased.inject('t.host', later)
|
||||
|
||||
disposeFrame()
|
||||
expect(first).toHaveBeenCalledTimes(2)
|
||||
expect(later).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.spec('t.host')).toBeDefined()
|
||||
disposeReplacement()
|
||||
})
|
||||
|
||||
it('cancels a waiting injection when its contributor is already unloading', async () => {
|
||||
const bench = await boot()
|
||||
const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
|
||||
let release = (): void => {}
|
||||
const blocked = new Promise<void>((resolve) => { release = resolve })
|
||||
const pauseUnload = vi.fn(async () => { await blocked })
|
||||
const contributor = bench.ctx.plugin({
|
||||
name: 'unloading-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.slots.inject('t.host', setup)
|
||||
ctx.effect(() => pauseUnload, 'pause contributor unload')
|
||||
},
|
||||
})
|
||||
await contributor.await()
|
||||
const disposing = contributor.dispose()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
|
||||
release()
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const componentA = (): null => null
|
||||
const componentB = (): null => null
|
||||
const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
|
||||
name,
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
|
||||
})
|
||||
const first = mount('replacement-a', componentA)
|
||||
await first.await()
|
||||
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
|
||||
await first.dispose()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
const second = mount('replacement-b', componentB)
|
||||
await second.await()
|
||||
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
|
||||
})
|
||||
|
||||
it('releases service-layer store state when the declaration collapses', async () => {
|
||||
const bench = await boot()
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
|
||||
const oldEntry = host.entriesOf('t.host')[0]
|
||||
expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
|
||||
disposeFrame()
|
||||
expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const panelEntry = host.entriesOf('t.panel')[0]
|
||||
expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
|
||||
expect(handle.create).toHaveBeenLastCalledWith('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderer install seam', () => {
|
||||
it('throws on renderSlot before install (boot-order guidance)', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
@@ -85,29 +85,85 @@ describe('TranscriptAdapter', () => {
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
at(2, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(7, 0, 'c1', '结果'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
|
||||
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies steering on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'live steer' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([])
|
||||
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }))
|
||||
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }))
|
||||
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
|
||||
})
|
||||
|
||||
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
|
||||
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
adapter.reset([
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, inserted: [queued],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [canceled],
|
||||
} }),
|
||||
at(4, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
} }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
|
||||
at(6, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [context],
|
||||
} }),
|
||||
at(7, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
|
||||
])
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a compact/* provenance record) and a future type core
|
||||
@@ -205,10 +261,15 @@ describe('TranscriptAdapter', () => {
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
|
||||
expect(adapter.nodes()).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: 'instructions',
|
||||
}])
|
||||
})
|
||||
|
||||
it('ignores a foreign plugin s replacement user/message', () => {
|
||||
@@ -371,6 +432,14 @@ describe('TranscriptAdapter', () => {
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
|
||||
})
|
||||
|
||||
it('represents command input omitted by the host as null', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'command', name: 'feedback', args: null, outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
|
||||
@@ -415,4 +484,48 @@ describe('TranscriptAdapter', () => {
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant timing', () => {
|
||||
const base = 1_700_000_000_000
|
||||
|
||||
it('derives step timing across a window rebuild (start + first token + completion)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 0),
|
||||
ev.user(1, '问'),
|
||||
ev.stepStart(2, 0),
|
||||
ev.chunkStart(3, 0),
|
||||
ev.chunkText(4, 0, '答'),
|
||||
ev.chunkText(5, 0, '案'),
|
||||
ev.assistant(6, 0, '答案'),
|
||||
ev.turnEnd(7, 0),
|
||||
])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the same timing on the live append path, first token winning once', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问')])
|
||||
adapter.append(ev.stepStart(1, 0))
|
||||
adapter.append(ev.chunkText(2, 0, '首'))
|
||||
adapter.append(ev.chunkText(3, 0, '次'))
|
||||
adapter.append(ev.assistant(4, 0, '首次'))
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
@@ -16,16 +17,22 @@ interface Bench {
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
@@ -149,26 +149,37 @@ describe('WorkspacesService', () => {
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
|
||||
})
|
||||
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
// Blank session already parked in alpha (cwd == workspace path canon).
|
||||
// Stray blank at alpha's path but NOT accounted under alpha (a CLI
|
||||
// session birthed at the host cwd), sorted before the member blank:
|
||||
// the scan must skip it and keep looking for a member hit.
|
||||
{ sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Blank session parked in alpha (cwd == workspace path canon AND
|
||||
// accounted under alpha): the reuse hit.
|
||||
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Non-blank sibling in beta must never be reused.
|
||||
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
|
||||
// Stray blank at gamma's path but NOT accounted under gamma (a CLI
|
||||
// session birthed at the host cwd): cwd alone must not hijack it —
|
||||
// reuse would open a session gamma cannot show, so New Session mints
|
||||
// a fresh accounted one instead.
|
||||
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
|
||||
] as never[],
|
||||
}))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
|
||||
// Hit: same workspace → the parked blank session comes back, no create RPC.
|
||||
// Hit: same workspace → the parked member blank comes back (the earlier
|
||||
// cwd-matching non-member stray is skipped), no create RPC.
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
|
||||
expect(api.callsOf('session.create')).toEqual([])
|
||||
// Resolution guarantee: the id is binding-resolvable synchronously.
|
||||
@@ -181,6 +192,12 @@ describe('WorkspacesService', () => {
|
||||
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
|
||||
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
|
||||
|
||||
// Miss: the stray blank matches gamma's path but is not a gamma member →
|
||||
// never reused, a fresh accounted session is created instead.
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
|
||||
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
|
||||
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
|
||||
@@ -196,7 +213,7 @@ describe('WorkspacesService', () => {
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user