Merge remote-tracking branch 'origin/master' into codex/status-bar-token-metrics
# Conflicts: # docs/cordis-catalog/events.md # docs/event-producer-consumer.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/runtime/tests/session.spec.ts # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/queue-dock.spec.tsx # packages/core/agent-loop/README.i18n.yaml # packages/core/agent/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts
This commit is contained in:
@@ -9,7 +9,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId, SessionMetrics } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
SessionId, SessionMetrics, SessionProjectionsBlock,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
@@ -26,14 +28,14 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
function histResponse(
|
||||
events: SessionEvent[],
|
||||
hasMore = false,
|
||||
todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[],
|
||||
projections?: SessionProjectionsBlock,
|
||||
metrics?: SessionMetrics,
|
||||
) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({
|
||||
events: entries(events) as never[],
|
||||
hasMore,
|
||||
...todos === undefined ? {} : { todos },
|
||||
...projections === undefined ? {} : { projections },
|
||||
...metrics === undefined ? {} : { metrics },
|
||||
}))
|
||||
}
|
||||
@@ -145,7 +147,7 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('retains live capacity across metrics, replaces or clears it on requests, and resets at subscription', async () => {
|
||||
it('keeps live capacity separate from durable metrics, replaces or clears it on requests, and resets at subscription', async () => {
|
||||
const { session } = await opened()
|
||||
const current = metrics(8, 10)
|
||||
session.handleMuxEnvelope('m1' as never, {
|
||||
@@ -154,6 +156,7 @@ describe('live event path', () => {
|
||||
metrics: current,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
|
||||
session.handleMuxEnvelope('request-1' as never, {
|
||||
type: 'session/model-request',
|
||||
@@ -164,10 +167,8 @@ describe('live event path', () => {
|
||||
model: 'alpha',
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toEqual({
|
||||
...current,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('m2' as never, {
|
||||
type: 'session/metrics',
|
||||
@@ -179,10 +180,8 @@ describe('live event path', () => {
|
||||
sessionId: SID,
|
||||
metrics: metrics(7, 11, { uncachedInputTokens: 2 }),
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toEqual({
|
||||
...current,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
const ordinaryUpdate = metrics(9, 11, { contextTokens: 40 })
|
||||
session.handleMuxEnvelope('m4' as never, {
|
||||
@@ -190,10 +189,8 @@ describe('live event path', () => {
|
||||
sessionId: SID,
|
||||
metrics: ordinaryUpdate,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toEqual({
|
||||
...ordinaryUpdate,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(ordinaryUpdate)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('request-2' as never, {
|
||||
type: 'session/model-request',
|
||||
@@ -204,6 +201,7 @@ describe('live event path', () => {
|
||||
model: 'without-capacity',
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toEqual(ordinaryUpdate)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
@@ -211,6 +209,7 @@ describe('live event path', () => {
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
session.handleMuxEnvelope('request-3' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
@@ -226,9 +225,51 @@ describe('live event path', () => {
|
||||
sessionId: SID,
|
||||
metrics: nextGeneration,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toEqual({
|
||||
...nextGeneration,
|
||||
contextWindow: 256_000,
|
||||
expect(session.getSnapshot().metrics).toBe(nextGeneration)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000)
|
||||
})
|
||||
|
||||
it('publishes a subscribed reset when capacity arrived before durable metrics', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('request' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(6, 'cmd-live', 'plan'))
|
||||
let command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
|
||||
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||
command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
||||
|
||||
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.commandRun(6, 'cmd-live', 'plan'),
|
||||
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -286,42 +327,6 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
|
||||
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
|
||||
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
|
||||
const { session } = await opened()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.todoWrite(6, listA))
|
||||
expect(session.getSnapshot().todos).toEqual(listA)
|
||||
feed(ev.todoWrite(7, listB))
|
||||
expect(session.getSnapshot().todos).toEqual(listB)
|
||||
// Window replay converges on the same last snapshot (history contains both writes).
|
||||
const replayed = makeSession()
|
||||
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
|
||||
await replayed.session.open()
|
||||
expect(replayed.session.getSnapshot().todos).toEqual(listB)
|
||||
})
|
||||
|
||||
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
|
||||
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
|
||||
// Cold open: the page window carries NO todo/write; the projection rides the response.
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
|
||||
await session.open()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// Paging an older window in must not clear the session-level projection.
|
||||
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// A later live write still overrides the seeded projection.
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
|
||||
@@ -335,37 +340,6 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
// The missed range contained a todo/write that the repulled page no longer
|
||||
// covers; the response's session-level projection is the only carrier.
|
||||
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
|
||||
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.history').length).toBe(2)
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(session.getSnapshot().todos).toEqual(current)
|
||||
})
|
||||
|
||||
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
|
||||
// Live write lands, then the host crashes before persisting it: the
|
||||
// authoritative log holds no todo/write, so the resync tail response
|
||||
// carries no projection — an omitted field on a tail request is the empty
|
||||
// list, not a missing carrier, and the rolled-back plan must disappear.
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
|
||||
Reference in New Issue
Block a user