Merge branch 'claude/web-llm-pi-ai-config-385e24' into claude/pi-ai-model-discovery

# Conflicts:
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/event-producer-consumer.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-06 10:50:20 +08:00
822 changed files with 18341 additions and 14753 deletions

View File

@@ -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,7 +82,12 @@ 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' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>

View File

@@ -2,12 +2,45 @@ import { createMessage } 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('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 +124,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))
})
})

View File

@@ -7,9 +7,7 @@ import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, MuxFrame, RpcId, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
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'
@@ -17,7 +15,7 @@ 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
@@ -151,16 +149,16 @@ describe('queue snapshot intake', () => {
const durable = {
seq: 0,
time: 1_700_000_000_000,
type: 'steering/message',
type: 'user/message',
surfaceOp: 'append',
data: { turn: 1, message },
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 === 'steering')).toHaveLength(1)
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 },
@@ -170,6 +168,32 @@ describe('queue snapshot intake', () => {
})
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', () => {

View File

@@ -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' } },
}),
]))

View File

@@ -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)

View File

@@ -206,7 +206,7 @@ describe('live event path', () => {
expect(published).toEqual(['累计', null])
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
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 = [
@@ -215,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()
@@ -252,15 +240,14 @@ 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' && event.data.trigger.kind === 'retry')
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include a retry turn/start')
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')
@@ -285,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])
@@ -450,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) => {
@@ -470,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 }) }

View File

@@ -88,21 +88,14 @@ describe('TranscriptAdapter', () => {
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(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
at(2, { 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(3, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(4, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})