mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
feat(session)!: embed assistant streams in format v2
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionAssistantStreamAccumulator } from '../src/assistant-stream.ts'
|
||||
|
||||
describe('SessionAssistantStreamAccumulator', () => {
|
||||
it('replaces stale lifecycles, rejects frame gaps, and caches each baseline', () => {
|
||||
const accumulator = new SessionAssistantStreamAccumulator()
|
||||
const empty = accumulator.snapshot()
|
||||
expect(accumulator.snapshot()).toBe(empty)
|
||||
|
||||
accumulator.accept({
|
||||
type: 'start', attemptId: LlmAttemptId('stale'), revision: 2,
|
||||
startedTime: 1, turn: 1, step: 1,
|
||||
})
|
||||
expect(accumulator.snapshot()).toEqual({ revision: 2 })
|
||||
|
||||
accumulator.accept({
|
||||
type: 'start', attemptId: LlmAttemptId('current'), revision: 1,
|
||||
startedTime: 2, turn: 2, step: 3,
|
||||
})
|
||||
expect(accumulator.snapshot()).toMatchObject({
|
||||
revision: 1,
|
||||
activeAttempt: { attemptId: 'current', turn: 2, step: 3, nextIndex: 0, stream: [] },
|
||||
})
|
||||
|
||||
accumulator.accept({
|
||||
type: 'chunk', attemptId: LlmAttemptId('other'), revision: 2, index: 0,
|
||||
time: 4, chunk: { type: 'text-delta', index: 0, text: 'lost' },
|
||||
})
|
||||
expect(accumulator.snapshot()).toEqual({ revision: 2 })
|
||||
|
||||
accumulator.accept({
|
||||
type: 'start', attemptId: LlmAttemptId('settled'), revision: 3,
|
||||
startedTime: 3, turn: 2, step: 4,
|
||||
})
|
||||
accumulator.accept({
|
||||
type: 'chunk', attemptId: LlmAttemptId('settled'), revision: 4, index: 0,
|
||||
time: 5, chunk: { type: 'text-delta', index: 0, text: 'ok' },
|
||||
})
|
||||
const active = accumulator.snapshot()
|
||||
expect(active).toMatchObject({
|
||||
revision: 4,
|
||||
activeAttempt: {
|
||||
attemptId: 'settled', nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 5, index: 0, dt: [], texts: ['ok'] }],
|
||||
},
|
||||
})
|
||||
expect(accumulator.snapshot()).toBe(active)
|
||||
|
||||
accumulator.accept({
|
||||
type: 'end', attemptId: LlmAttemptId('settled'), revision: 5, index: 1,
|
||||
outcome: { kind: 'abandoned' },
|
||||
})
|
||||
expect(accumulator.snapshot()).toEqual({ revision: 5 })
|
||||
})
|
||||
})
|
||||
@@ -178,6 +178,7 @@ describe('Session attachment authorization', () => {
|
||||
{ ...event('assistant/message', SessionSeq(1), {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'image', attachment: message }],
|
||||
source: { provider: 'fixture', model: 'fixture' },
|
||||
@@ -191,10 +192,30 @@ describe('Session attachment authorization', () => {
|
||||
source: { kind: 'user' },
|
||||
})],
|
||||
}),
|
||||
event('assistant/chunk', SessionSeq(3), {
|
||||
event('assistant/attempt', SessionSeq(3), {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
|
||||
stream: [
|
||||
{
|
||||
type: 'chunk',
|
||||
time: 3,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'chunk',
|
||||
time: 3,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
event('assistant/attempt', SessionSeq(4), {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
stream: [{
|
||||
type: 'chunk',
|
||||
time: 4,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
|
||||
}],
|
||||
}),
|
||||
]
|
||||
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
|
||||
|
||||
@@ -27,13 +27,18 @@ export const ev = {
|
||||
}) }),
|
||||
stepStart: (seq: SessionSeq, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/start', data: { turn, step } }),
|
||||
chunkStart: (seq: SessionSeq, turn: number, step = 0, index = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }),
|
||||
chunkText: (seq: SessionSeq, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
|
||||
assistant: (seq: SessionSeq, turn: number, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn, step,
|
||||
stream: [
|
||||
{ type: 'chunk', time: 1_700_000_000_000 + seq, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
|
||||
{ type: 'text-chunks', time0: 1_700_000_000_000 + seq, index: 0, dt: [], texts: [body] },
|
||||
{
|
||||
type: 'chunk', time: 1_700_000_000_000 + seq,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'text', text: body } },
|
||||
},
|
||||
{ type: 'chunk', time: 1_700_000_000_000 + seq, chunk: { type: 'finish', reason: { kind: 'stop' } } },
|
||||
],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: text(body),
|
||||
|
||||
@@ -161,7 +161,6 @@ export class FakeApiClient {
|
||||
}
|
||||
assistantStreamBaseline: SessionAssistantStreamBaseline = {
|
||||
revision: 0,
|
||||
attempts: [],
|
||||
}
|
||||
workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
|
||||
items: [],
|
||||
@@ -393,9 +392,10 @@ export class FakeApiClient {
|
||||
yield {
|
||||
type: 'snapshot',
|
||||
header: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
id: sessionId,
|
||||
createdAt: 0,
|
||||
isSeeded: false,
|
||||
...(request.address.kind === 'subagent'
|
||||
? { origin: 'subagent' as const, parentSession: request.address.parentSessionId }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Packed history records become one event-shaped Client value per wire record. */
|
||||
/** V2 history records become one event-shaped Client value per wire record. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
@@ -26,53 +26,69 @@ describe('Session history record projection', () => {
|
||||
expect(historyRecordLastSeq(ordinary)).toBe(7)
|
||||
})
|
||||
|
||||
it('retains one packed text row without copying or reshaping it', () => {
|
||||
const packed: SessionHistoryRecord = {
|
||||
type: 'chunks',
|
||||
it('retains one message with an embedded compact text stream', () => {
|
||||
const message: SessionHistoryRecord = {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'chunkrow/text-chunks',
|
||||
type: 'assistant/message',
|
||||
seq: 11,
|
||||
time: 20,
|
||||
data: { turn: 1, step: 2, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] },
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: {
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'abcd' }],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture-v2' },
|
||||
},
|
||||
stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const [entry] = historyEntries([packed])
|
||||
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
|
||||
const [entry] = historyEntries([message])
|
||||
if (entry?.type !== 'event') throw new Error('expected v2 history entry')
|
||||
const { event } = entry
|
||||
|
||||
expect(entry).toBe(packed)
|
||||
expect(event).toBe(packed.event)
|
||||
expect(historyRecordFirstSeq(packed)).toBe(11)
|
||||
expect(entry).toBe(message)
|
||||
expect(event).toBe(message.event)
|
||||
expect(historyRecordFirstSeq(message)).toBe(11)
|
||||
expect(event.time).toBe(20)
|
||||
expect(historyRecordLastSeq(packed)).toBe(14)
|
||||
expect(historyRecordLastSeq(message)).toBe(11)
|
||||
})
|
||||
|
||||
it('preserves a packed tool-call row and optional-name absence', () => {
|
||||
const packed: SessionHistoryRecord = {
|
||||
type: 'chunks',
|
||||
it('preserves an attempt stream and optional tool-name absence', () => {
|
||||
const attempt: SessionHistoryRecord = {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'chunkrow/tool-call-chunks',
|
||||
type: 'assistant/attempt',
|
||||
seq: 20,
|
||||
time: 200,
|
||||
data: {
|
||||
turn: 2,
|
||||
step: 4,
|
||||
index: 1,
|
||||
id: ToolCallId('call-1'),
|
||||
dt: [2, 3],
|
||||
args: ['', '{"x":', '1}'],
|
||||
stream: [{
|
||||
type: 'tool-call-chunks',
|
||||
time0: 200,
|
||||
index: 1,
|
||||
id: ToolCallId('call-1'),
|
||||
dt: [2, 3],
|
||||
args: ['', '{"x":', '1}'],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const [entry] = historyEntries([packed])
|
||||
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
|
||||
const [entry] = historyEntries([attempt])
|
||||
if (entry?.type !== 'event') throw new Error('expected v2 history entry')
|
||||
const { event } = entry
|
||||
|
||||
if (event.type !== 'chunkrow/tool-call-chunks') throw new Error('expected packed history event')
|
||||
expect(event).toBe(packed.event)
|
||||
expect(Object.hasOwn(event.data, 'name')).toBe(false)
|
||||
expect(historyRecordLastSeq(packed)).toBe(22)
|
||||
if (event.type !== 'assistant/attempt') throw new Error('expected Assistant attempt event')
|
||||
expect(event).toBe(attempt.event)
|
||||
const [record] = event.data.stream
|
||||
expect(record).toMatchObject({ type: 'tool-call-chunks', id: 'call-1' })
|
||||
expect(Object.hasOwn(record as object, 'name')).toBe(false)
|
||||
expect(historyRecordLastSeq(attempt)).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
|
||||
import type {
|
||||
ChunkRowEvent,
|
||||
SessionFollowFrame,
|
||||
SessionPage,
|
||||
SessionWireEvent,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { SessionFollowFrame, SessionPage, SessionWireEvent } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
|
||||
|
||||
/** Append a production-shaped human prompt to the session surface. */
|
||||
@@ -33,6 +27,7 @@ function appendAssistantText(session: Session, text: string, step: number): Sess
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
}),
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: [text] }],
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -94,25 +89,87 @@ async function disposeFollow(
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
/** Expand packed page records for assertions over the logical journal. */
|
||||
/** Read scalar v2 page records for assertions over the logical journal. */
|
||||
function pageEvents(page: SessionPage): SessionWireEvent[] {
|
||||
return page.records.flatMap(record => record.type === 'event'
|
||||
? [record.event]
|
||||
: decodeStorageRecord(chunkRow(record.event)).map(event => event as unknown as SessionWireEvent))
|
||||
}
|
||||
|
||||
function chunkRow(event: ChunkRowEvent): ChunkRow {
|
||||
switch (event.type) {
|
||||
case 'chunkrow/text-chunks':
|
||||
return { type: 'text-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
|
||||
case 'chunkrow/reasoning-chunks':
|
||||
return { type: 'reasoning-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
|
||||
case 'chunkrow/tool-call-chunks':
|
||||
return { type: 'tool-call-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
|
||||
}
|
||||
return page.records.map(record => record.event)
|
||||
}
|
||||
|
||||
describe('Session history raw journal', () => {
|
||||
it('opens an empty opted-in Assistant baseline before any live attempt exists', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
|
||||
const abort = new AbortController()
|
||||
const iterator = history.follow({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
assistantStream: true,
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: { type: 'snapshot', assistantStream: { revision: 0 } },
|
||||
})
|
||||
abort.abort()
|
||||
await iterator.next()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('filters foreign and opening-baseline frames buffered during the source observation', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
const agent = { id: session.id, session, status: 'running', ctx } as Agent
|
||||
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
|
||||
const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (...args) => {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return originalObserve(...args)
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const iterator = history.follow({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
assistantStream: true,
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
const opening = iterator.next()
|
||||
await entered.promise
|
||||
|
||||
const attemptId = LlmAttemptId('buffered-opening-attempt')
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: { type: 'start', attemptId, revision: 1, startedTime: 1, turn: 1, step: 1 },
|
||||
})
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 2, index: 0,
|
||||
time: 2, chunk: { type: 'text-delta', index: 0, text: 'buffered' },
|
||||
},
|
||||
})
|
||||
const foreign = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent: { id: foreign.id, session: foreign, status: 'running', ctx } as Agent,
|
||||
frame: {
|
||||
type: 'start', attemptId: LlmAttemptId('foreign-attempt'), revision: 1,
|
||||
startedTime: 1, turn: 1, step: 1,
|
||||
},
|
||||
})
|
||||
release.resolve(undefined)
|
||||
await expect(opening).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: { type: 'snapshot', assistantStream: { revision: 2 } },
|
||||
})
|
||||
|
||||
const next = iterator.next()
|
||||
const durable = session.append('turn/start', { turn: 1 })
|
||||
await expect(next).resolves.toEqual({ done: false, value: { type: 'event', event: durable } })
|
||||
abort.abort()
|
||||
await iterator.next()
|
||||
observe.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('opens an opted-in assistant baseline and preserves mixed live FIFO order', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
@@ -126,12 +183,10 @@ describe('Session history raw journal', () => {
|
||||
type: 'start', attemptId, revision: 1, startedTime: 100,
|
||||
turn: 1, step: 1,
|
||||
})
|
||||
const firstChunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' },
|
||||
})
|
||||
const firstChunk = { type: 'text-delta', index: 0, text: 'a' } as const
|
||||
emit({
|
||||
type: 'chunk', attemptId, revision: 2, index: 0,
|
||||
chunk: firstChunk.data.chunk, legacyChunkSeq: firstChunk.seq,
|
||||
time: 1, chunk: firstChunk,
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const iterator = history.follow({
|
||||
@@ -145,35 +200,29 @@ describe('Session history raw journal', () => {
|
||||
type: 'snapshot',
|
||||
assistantStream: {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 100,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [firstChunk.data.chunk],
|
||||
legacyChunkSeqs: [firstChunk.seq],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const nextChunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
|
||||
})
|
||||
const nextFrame: AssistantStreamFrame = {
|
||||
type: 'chunk', attemptId, revision: 3, index: 1,
|
||||
chunk: nextChunk.data.chunk, legacyChunkSeq: nextChunk.seq,
|
||||
time: 2, chunk: { type: 'text-delta', index: 0, text: 'b' },
|
||||
}
|
||||
emit(nextFrame)
|
||||
const message = appendAssistantText(session, 'ab', 1)
|
||||
const endFrame: AssistantStreamFrame = {
|
||||
type: 'end', attemptId, revision: 4, index: 2, outcome: 'committed',
|
||||
legacyChunkSeqs: [firstChunk.seq, nextChunk.seq],
|
||||
type: 'end', attemptId, revision: 4, index: 2,
|
||||
outcome: { kind: 'committed', eventType: 'assistant/message', seq: message.seq },
|
||||
}
|
||||
emit(endFrame)
|
||||
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
done: false, value: { type: 'event', event: nextChunk },
|
||||
})
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
done: false, value: { type: 'assistant-stream', frame: nextFrame },
|
||||
})
|
||||
@@ -201,14 +250,12 @@ describe('Session history raw journal', () => {
|
||||
turn: 1, step: 1,
|
||||
},
|
||||
})
|
||||
const oldChunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'old' },
|
||||
})
|
||||
const oldChunk = { type: 'text-delta', index: 0, text: 'old' } as const
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 2, index: 0,
|
||||
chunk: oldChunk.data.chunk, legacyChunkSeq: oldChunk.seq,
|
||||
time: 101, chunk: oldChunk,
|
||||
},
|
||||
})
|
||||
const abort = new AbortController()
|
||||
@@ -224,14 +271,14 @@ describe('Session history raw journal', () => {
|
||||
type: 'snapshot',
|
||||
assistantStream: {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 100,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [oldChunk.data.chunk],
|
||||
legacyChunkSeqs: [oldChunk.seq],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 101, index: 0, dt: [], texts: ['old'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -265,14 +312,12 @@ describe('Session history raw journal', () => {
|
||||
turn: 1, step: 1,
|
||||
},
|
||||
})
|
||||
const chunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'after gap' },
|
||||
})
|
||||
const chunk = { type: 'text-delta', index: 0, text: 'after gap' } as const
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 3, index: 0,
|
||||
chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
|
||||
time: 101, chunk,
|
||||
},
|
||||
})
|
||||
const abort = new AbortController()
|
||||
@@ -286,7 +331,7 @@ describe('Session history raw journal', () => {
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
assistantStream: { revision: 3, attempts: [] },
|
||||
assistantStream: { revision: 3 },
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -307,14 +352,12 @@ describe('Session history raw journal', () => {
|
||||
turn: 1, step: 1,
|
||||
},
|
||||
})
|
||||
const chunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'out of order' },
|
||||
})
|
||||
const chunk = { type: 'text-delta', index: 0, text: 'out of order' } as const
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 2, index: 1,
|
||||
chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
|
||||
time: 101, chunk,
|
||||
},
|
||||
})
|
||||
const abort = new AbortController()
|
||||
@@ -328,7 +371,7 @@ describe('Session history raw journal', () => {
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
assistantStream: { revision: 2, attempts: [] },
|
||||
assistantStream: { revision: 2 },
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -364,7 +407,7 @@ describe('Session history raw journal', () => {
|
||||
const first = await firstIterator.next()
|
||||
if (first.done || first.value.type !== 'snapshot') throw new Error('first follow did not open')
|
||||
const baseline = first.value.assistantStream
|
||||
expect(baseline).toMatchObject({ revision: 1, attempts: [{ attemptId }] })
|
||||
expect(baseline).toMatchObject({ revision: 1, activeAttempt: { attemptId } })
|
||||
const second = await secondIterator.next()
|
||||
if (second.done || second.value.type !== 'snapshot') throw new Error('second follow did not open')
|
||||
expect(second.value.assistantStream).toEqual(baseline)
|
||||
@@ -392,7 +435,7 @@ describe('Session history raw journal', () => {
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
assistantStream: { revision: 0, attempts: [] },
|
||||
assistantStream: { revision: 0 },
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -466,7 +509,7 @@ describe('Session history raw journal', () => {
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
assistantStream: { revision: 1, attempts: [{ attemptId: frame.attemptId }] },
|
||||
assistantStream: { revision: 1, activeAttempt: { attemptId: frame.attemptId } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -484,74 +527,6 @@ describe('Session history raw journal', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('delivers a baseline-framed chunk whose durable event lands after the opening snapshot', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
const agent = { id: session.id, session, status: 'running', ctx } as Agent
|
||||
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
|
||||
const attemptId = LlmAttemptId('opening-durable-cut-attempt')
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'start', attemptId, revision: 1, startedTime: 100,
|
||||
turn: 1, step: 1,
|
||||
},
|
||||
})
|
||||
const observationCaptured = Promise.withResolvers<undefined>()
|
||||
const releaseObservation = Promise.withResolvers<undefined>()
|
||||
const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
|
||||
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (sessionId, options) => {
|
||||
const observation = await originalObserve(sessionId, options)
|
||||
observationCaptured.resolve(undefined)
|
||||
await releaseObservation.promise
|
||||
return observation
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const iterator = history.follow({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
assistantStream: true,
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
|
||||
try {
|
||||
const opening = iterator.next()
|
||||
await observationCaptured.promise
|
||||
const chunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'cut-safe' },
|
||||
})
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 2, index: 0,
|
||||
chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
|
||||
},
|
||||
})
|
||||
const after = session.append('turn/start', { turn: 2 })
|
||||
releaseObservation.resolve(undefined)
|
||||
|
||||
await expect(opening).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
records: [],
|
||||
assistantStream: {
|
||||
revision: 2,
|
||||
attempts: [{ attemptId, legacyChunkSeqs: [chunk.seq] }],
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
done: false, value: { type: 'event', event: chunk },
|
||||
})
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
done: false, value: { type: 'event', event: after },
|
||||
})
|
||||
} finally {
|
||||
releaseObservation.resolve(undefined)
|
||||
observe.mockRestore()
|
||||
await disposeFollow(ctx, iterator, abort)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not release an old-lifecycle frame after the opening baseline resets to revision one', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
@@ -582,14 +557,12 @@ describe('Session history raw journal', () => {
|
||||
try {
|
||||
const opening = iterator.next()
|
||||
await observationStarted.promise
|
||||
const oldChunk = session.append('assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'old lifecycle' },
|
||||
})
|
||||
const oldChunk = { type: 'text-delta', index: 0, text: 'old lifecycle' } as const
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 2, index: 0,
|
||||
chunk: oldChunk.data.chunk, legacyChunkSeq: oldChunk.seq,
|
||||
time: 101, chunk: oldChunk,
|
||||
},
|
||||
})
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
@@ -606,7 +579,9 @@ describe('Session history raw journal', () => {
|
||||
type: 'snapshot',
|
||||
assistantStream: {
|
||||
revision: 1,
|
||||
attempts: [{ attemptId, startedTime: 200, turn: 2, step: 1, chunks: [] }],
|
||||
activeAttempt: {
|
||||
attemptId, startedTime: 200, turn: 2, step: 1, nextIndex: 0, stream: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -649,8 +624,7 @@ describe('Session history raw journal', () => {
|
||||
ctx.emit('agent/assistant-stream', {
|
||||
agent,
|
||||
frame: {
|
||||
type: 'end', attemptId, revision: 2, index: 0,
|
||||
outcome: 'aborted', legacyChunkSeqs: [],
|
||||
type: 'end', attemptId, revision: 2, index: 0, outcome: { kind: 'abandoned' },
|
||||
},
|
||||
})
|
||||
const next = session.append('turn/end', {
|
||||
@@ -821,25 +795,23 @@ describe('Session history raw journal', () => {
|
||||
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
|
||||
})
|
||||
|
||||
it('paginates a message with many provenance sources without variadic argument expansion', async () => {
|
||||
it('paginates a message with a large embedded stream without expanding physical records', async () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const sources = Array.from({ length: 128 }, () => session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'x' },
|
||||
}).seq)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const texts = Array.from({ length: 128 }, () => 'x')
|
||||
const message = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
|
||||
content: [{ type: 'text', text: 'x'.repeat(texts.length) }],
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: sources })
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: texts.slice(1).map(() => 0), texts }],
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const scalarMin = Math.min
|
||||
const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
|
||||
@@ -853,68 +825,55 @@ describe('Session history raw journal', () => {
|
||||
maxMessages: 1,
|
||||
})
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
expect(pageEvents(response.value).map(event => event.seq)).toEqual([...sources, message.seq])
|
||||
expect(response.value.records.filter(record => record.type === 'chunks')).toHaveLength(1)
|
||||
expect(pageEvents(response.value).map(event => event.seq)).toEqual([message.seq])
|
||||
expect(response.value.records).toEqual([{ type: 'event', event: message }])
|
||||
expect(response.value.hasMore).toBe(true)
|
||||
} finally {
|
||||
min.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes reasoning and tool-call runs as aligned chunk events', async () => {
|
||||
it('keeps an earlier declared source on the same message-aligned page', async () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
const source = session.append('request/context', { provider: 'p', model: 'm' })
|
||||
const laterSource = session.append('request/context', { provider: 'p', model: 'm' })
|
||||
const message = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'with source' }], source: { kind: 'user' },
|
||||
}), { sourceEventSeqs: [source.seq, laterSource.seq], surfaceOp: 'append' })
|
||||
|
||||
const response = await remote.page({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
throughSeq: message.seq,
|
||||
maxMessages: 1,
|
||||
})
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
expect(pageEvents(response.value).map(event => event.seq)).toEqual([source.seq, laterSource.seq, message.seq])
|
||||
expect(response.value.hasMore).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps compact reasoning and tool-call runs nested in one attempt event', async () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
const reasoning = [0, 1, 2].map(index => session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: `r${String(index)}` },
|
||||
}))
|
||||
const callId = ToolCallId('packed-call')
|
||||
const toolCall = [0, 1, 2].map(index => session.append('assistant/chunk', {
|
||||
const attempt = session.append('assistant/attempt', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 1, id: callId, argumentsDelta: `a${String(index)}` },
|
||||
}))
|
||||
stream: [
|
||||
{ type: 'reasoning-chunks', time0: 1, index: 0, dt: [1, 1], texts: ['r0', 'r1', 'r2'] },
|
||||
{ type: 'tool-call-chunks', time0: 4, index: 1, id: callId, dt: [1, 1], args: ['a0', 'a1', 'a2'] },
|
||||
],
|
||||
})
|
||||
|
||||
const response = await remote.page({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
throughSeq: session.seq - 1,
|
||||
})
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
expect(response.value.records).toEqual([
|
||||
{
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/reasoning-chunks',
|
||||
seq: reasoning[0]?.seq,
|
||||
time: reasoning[0]?.time,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
index: 0,
|
||||
dt: reasoning.slice(1).map((event, index) => event.time - (reasoning[index]?.time ?? 0)),
|
||||
texts: ['r0', 'r1', 'r2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/tool-call-chunks',
|
||||
seq: toolCall[0]?.seq,
|
||||
time: toolCall[0]?.time,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
index: 1,
|
||||
id: callId,
|
||||
dt: toolCall.slice(1).map((event, index) => event.time - (toolCall[index]?.time ?? 0)),
|
||||
args: ['a0', 'a1', 'a2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.value.records).toEqual([{ type: 'event', event: attempt }])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -154,14 +154,14 @@ describe('session.history projections block', () => {
|
||||
const snapshot = await opening(remote(ctx), child.id)
|
||||
|
||||
expect(snapshot.header).toEqual({
|
||||
version: 1,
|
||||
version: 2,
|
||||
id: child.id,
|
||||
createdAt: child.header.createdAt,
|
||||
cwd: '/workspace',
|
||||
parentSession: parent.id,
|
||||
seedLength: inheritedEventCount,
|
||||
isSeeded: true,
|
||||
})
|
||||
expect(snapshot.header).not.toHaveProperty('isSeeded')
|
||||
expect(snapshot.header).not.toHaveProperty('seedLength')
|
||||
})
|
||||
|
||||
it('tracks pending and used model selections across repeated request headers', async () => {
|
||||
|
||||
@@ -99,29 +99,6 @@ describe('Session open', () => {
|
||||
expect(api.followStarts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('lands a packed live record in openState=error instead of crashing the stream loop', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
|
||||
// The live tail may carry only events; a packed record breaks that contract.
|
||||
await api.pushFollow(SID, {
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/text-chunks',
|
||||
seq: 6,
|
||||
time: 6,
|
||||
data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] },
|
||||
},
|
||||
} as never)
|
||||
|
||||
await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
|
||||
expect(session.getSnapshot().openError).toMatchObject({
|
||||
code: 'gateway/internal', message: 'session live stream emitted a packed history record',
|
||||
})
|
||||
})
|
||||
|
||||
it('lands a Gateway-marked stream failure in openState=error', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.reject(new Error('socket died'))
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('search', () => {
|
||||
})
|
||||
|
||||
describe('scope tree', () => {
|
||||
it('publishes live assistant chunks and durable settlement atomically through one event source', async () => {
|
||||
it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
@@ -144,17 +144,10 @@ describe('scope tree', () => {
|
||||
expect(binding.session.getSnapshot().openState).toBe('open')
|
||||
})
|
||||
const attemptId = LlmAttemptId('web-live-attempt')
|
||||
const durableChunk = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 0, time: 1,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live' } },
|
||||
},
|
||||
}
|
||||
const durableMessage = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/message', seq: 1, time: 2,
|
||||
type: 'assistant/message', seq: 0, time: 2,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -164,8 +157,8 @@ describe('scope tree', () => {
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
id: 'message-1',
|
||||
},
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['live'] }],
|
||||
},
|
||||
sourceEventSeqs: [0],
|
||||
surfaceOp: 'append' as const,
|
||||
},
|
||||
}
|
||||
@@ -181,16 +174,12 @@ describe('scope tree', () => {
|
||||
turn: 1, step: 1,
|
||||
},
|
||||
})
|
||||
await b.api.pushFollow(sid('s1'), durableChunk)
|
||||
await Promise.resolve()
|
||||
expect(binding.eventSource.getSnapshot().entries).toEqual([])
|
||||
|
||||
await b.api.pushFollow(sid('s1'), {
|
||||
type: 'assistant-stream',
|
||||
frame: {
|
||||
type: 'chunk', attemptId, revision: 2, index: 0,
|
||||
chunk: durableChunk.event.data.chunk,
|
||||
legacyChunkSeq: 0,
|
||||
time: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'live' },
|
||||
},
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
@@ -204,8 +193,7 @@ describe('scope tree', () => {
|
||||
type: 'assistant-stream',
|
||||
frame: {
|
||||
type: 'end', attemptId, revision: 3, index: 1,
|
||||
outcome: 'committed',
|
||||
legacyChunkSeqs: [0],
|
||||
outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
|
||||
},
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
@@ -213,8 +201,8 @@ describe('scope tree', () => {
|
||||
})
|
||||
|
||||
expect(publications).toEqual([
|
||||
['assistant/chunk'],
|
||||
['assistant/chunk', 'assistant/message'],
|
||||
['assistant/live-chunk'],
|
||||
['assistant/live-chunk', 'assistant/message'],
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
@@ -222,28 +210,15 @@ describe('scope tree', () => {
|
||||
it('replaces an active assistant baseline on reconnect without duplicate chunks', async () => {
|
||||
const b = bench()
|
||||
const attemptId = LlmAttemptId('reconnect-attempt')
|
||||
const first = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 0, time: 1,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } },
|
||||
},
|
||||
}
|
||||
const second = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 1, time: 2,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } },
|
||||
},
|
||||
}
|
||||
let records = [first] as never[]
|
||||
let records: never[] = []
|
||||
b.api.onHistory = () => Promise.resolve(ok({ records, hasMore: false }))
|
||||
b.api.assistantStreamBaseline = {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId, startedTime: 1, turn: 1, step: 1,
|
||||
chunks: [first.event.data.chunk], legacyChunkSeqs: [0],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }],
|
||||
},
|
||||
}
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
@@ -253,14 +228,14 @@ describe('scope tree', () => {
|
||||
expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
records = [first, second] as never[]
|
||||
records = []
|
||||
b.api.assistantStreamBaseline = {
|
||||
revision: 3,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId, startedTime: 1, turn: 1, step: 1,
|
||||
chunks: [first.event.data.chunk, second.event.data.chunk],
|
||||
legacyChunkSeqs: [0, 1],
|
||||
}],
|
||||
nextIndex: 2,
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] }],
|
||||
},
|
||||
}
|
||||
b.api.failStreams(new RemoteStreamCarrierError('lost'))
|
||||
await vi.waitFor(() => {
|
||||
@@ -268,57 +243,20 @@ describe('scope tree', () => {
|
||||
expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('publishes a baseline-framed chunk when its durable event follows the opening cut', async () => {
|
||||
const b = bench()
|
||||
const attemptId = LlmAttemptId('opening-cut-attempt')
|
||||
const chunk = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 0, time: 1,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'late durable' } },
|
||||
},
|
||||
}
|
||||
b.api.onHistory = () => Promise.resolve(ok({ records: [], hasMore: false }))
|
||||
b.api.followCursor = -1
|
||||
b.api.assistantStreamBaseline = {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
attemptId, startedTime: 1, turn: 1, step: 1,
|
||||
chunks: [chunk.event.data.chunk], legacyChunkSeqs: [0],
|
||||
}],
|
||||
}
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
if (binding === undefined) throw new Error('expected Session binding')
|
||||
await vi.waitFor(() => {
|
||||
expect(binding.session.getSnapshot().openState).toBe('open')
|
||||
})
|
||||
|
||||
await b.api.pushFollow(sid('s1'), chunk)
|
||||
await vi.waitFor(() => {
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
|
||||
})
|
||||
expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1)
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => (
|
||||
entry.event.type === 'assistant/live-chunk' && entry.event.data.chunk.type === 'text-delta'
|
||||
? entry.event.data.chunk.text
|
||||
: undefined
|
||||
))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('stages a reconnect-tail assistant settlement behind its exact active attempt', async () => {
|
||||
const b = bench()
|
||||
const attemptId = LlmAttemptId('reconnect-settlement-attempt')
|
||||
const priorChunk = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 0, time: 10,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'retry ' } },
|
||||
},
|
||||
}
|
||||
const priorMessage = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/message', seq: 1, time: 11,
|
||||
type: 'assistant/message', seq: 0, time: 11,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -328,22 +266,15 @@ describe('scope tree', () => {
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
id: 'prior-attempt-message',
|
||||
},
|
||||
stream: [{ type: 'text-chunks', time0: 10, index: 0, dt: [], texts: ['retry '] }],
|
||||
},
|
||||
sourceEventSeqs: [0],
|
||||
surfaceOp: 'append' as const,
|
||||
},
|
||||
}
|
||||
const currentChunk = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 2, time: 20,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'settled' } },
|
||||
},
|
||||
}
|
||||
const currentMessage = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/message', seq: 3, time: 21,
|
||||
type: 'assistant/message', seq: 1, time: 21,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -353,25 +284,25 @@ describe('scope tree', () => {
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
id: 'current-attempt-message',
|
||||
},
|
||||
stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [], texts: ['settled'] }],
|
||||
},
|
||||
sourceEventSeqs: [2],
|
||||
surfaceOp: 'append' as const,
|
||||
},
|
||||
}
|
||||
b.api.onHistory = () => Promise.resolve(ok({
|
||||
records: [priorChunk, priorMessage, currentChunk] as never[],
|
||||
records: [priorMessage, currentMessage] as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
b.api.assistantStreamBaseline = {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 20,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [currentChunk.event.data.chunk],
|
||||
legacyChunkSeqs: [2],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: currentMessage.event.data.stream,
|
||||
},
|
||||
}
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
@@ -381,39 +312,29 @@ describe('scope tree', () => {
|
||||
expect(binding.session.getSnapshot().openState).toBe('open')
|
||||
})
|
||||
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2])
|
||||
expect(binding.eventSource.getSnapshot().entries.at(1)?.event).toBe(priorMessage.event)
|
||||
expect(binding.eventSource.getSnapshot().entries.at(-1)?.event).toBe(currentChunk.event)
|
||||
|
||||
await b.api.pushFollow(sid('s1'), currentMessage)
|
||||
await Promise.resolve()
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2])
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
|
||||
.toEqual(['assistant/message', 'assistant/live-chunk'])
|
||||
expect(binding.eventSource.getSnapshot().entries[0]?.event).toBe(priorMessage.event)
|
||||
|
||||
await b.api.pushFollow(sid('s1'), {
|
||||
type: 'assistant-stream',
|
||||
frame: {
|
||||
type: 'end', attemptId, revision: 3, index: 1,
|
||||
outcome: 'committed', legacyChunkSeqs: [2],
|
||||
outcome: { kind: 'committed', eventType: 'assistant/message', seq: 1 },
|
||||
},
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2, 3])
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
|
||||
.toEqual(['assistant/message', 'assistant/live-chunk', 'assistant/message'])
|
||||
})
|
||||
expect(binding.eventSource.getSnapshot().change).toEqual({
|
||||
kind: 'append', entries: [currentMessage],
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces an invalid settlement with the authoritative post-end baseline', async () => {
|
||||
it('rebaselines a reconnect settlement whose end index skips the active tail', async () => {
|
||||
const b = bench()
|
||||
const attemptId = LlmAttemptId('reconnect-end-index-attempt')
|
||||
const chunk = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
type: 'assistant/chunk', seq: 0, time: 20,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'settled' } },
|
||||
},
|
||||
}
|
||||
const message = {
|
||||
type: 'event' as const,
|
||||
event: {
|
||||
@@ -427,51 +348,50 @@ describe('scope tree', () => {
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
id: 'current-attempt-message',
|
||||
},
|
||||
stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [], texts: ['settled'] }],
|
||||
},
|
||||
sourceEventSeqs: [0],
|
||||
surfaceOp: 'append' as const,
|
||||
},
|
||||
}
|
||||
let records = [chunk] as never[]
|
||||
b.api.onHistory = () => Promise.resolve(ok({
|
||||
records,
|
||||
records: [message] as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
b.api.assistantStreamBaseline = {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 20,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [chunk.event.data.chunk],
|
||||
legacyChunkSeqs: [0],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: message.event.data.stream,
|
||||
},
|
||||
}
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
if (binding === undefined) throw new Error('expected Session binding')
|
||||
await vi.waitFor(() => {
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
|
||||
.toEqual(['assistant/live-chunk'])
|
||||
})
|
||||
const openingRevision = binding.eventSource.getSnapshot().revision
|
||||
|
||||
await b.api.pushFollow(sid('s1'), message)
|
||||
await Promise.resolve()
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
|
||||
records = [chunk, message] as never[]
|
||||
b.api.assistantStreamBaseline = { revision: 3, attempts: [] }
|
||||
await b.api.pushFollow(sid('s1'), {
|
||||
type: 'assistant-stream',
|
||||
frame: {
|
||||
type: 'end', attemptId, revision: 3, index: 0,
|
||||
outcome: 'committed', legacyChunkSeqs: [0],
|
||||
outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
|
||||
},
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1])
|
||||
expect(b.api.activeFollows(sid('s1'))).toBe(1)
|
||||
expect(binding.eventSource.getSnapshot().revision).toBeGreaterThan(openingRevision)
|
||||
})
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
|
||||
.toEqual(['assistant/live-chunk'])
|
||||
})
|
||||
|
||||
it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
|
||||
@@ -610,17 +530,18 @@ describe('Agent scope disposal lifecycle', () => {
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
header: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
id: request.address.kind === 'session'
|
||||
? request.address.sessionId
|
||||
: request.address.childSessionId,
|
||||
createdAt: 0,
|
||||
isSeeded: false,
|
||||
},
|
||||
cursor: -1,
|
||||
records: [],
|
||||
hasMore: false,
|
||||
projections: { asOfSeq: -1, values: {} },
|
||||
assistantStream: { revision: 0, attempts: [] },
|
||||
assistantStream: { revision: 0 },
|
||||
} as const,
|
||||
})
|
||||
}
|
||||
@@ -685,12 +606,12 @@ describe('Agent scope disposal lifecycle', () => {
|
||||
done: false,
|
||||
value: {
|
||||
type: 'snapshot',
|
||||
header: { version: 1, id: sessionId, createdAt: 0 },
|
||||
header: { version: 2, id: sessionId, createdAt: 0, isSeeded: false },
|
||||
cursor: -1,
|
||||
records: [],
|
||||
hasMore: false,
|
||||
projections: { asOfSeq: -1, values: {} },
|
||||
assistantStream: { revision: 0, attempts: [] },
|
||||
assistantStream: { revision: 0 },
|
||||
} as const,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isRemoteFailure,
|
||||
RemoteStream,
|
||||
RemoteStreamCarrierError,
|
||||
type RemoteStreamOptions,
|
||||
@@ -42,18 +41,6 @@ function entry(seq: number): SessionEventEntry {
|
||||
return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
|
||||
}
|
||||
|
||||
function chunks(seq0: number): SessionHistoryRecord {
|
||||
return {
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/text-chunks',
|
||||
seq: seq0,
|
||||
time: seq0,
|
||||
data: { turn: 1, step: 1, index: 0, texts: ['a', 'b', 'c'], dt: [1, 1] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage {
|
||||
return { records, hasMore }
|
||||
}
|
||||
@@ -62,14 +49,15 @@ function snapshot(
|
||||
cursor: number,
|
||||
records: readonly SessionHistoryRecord[],
|
||||
hasMore = false,
|
||||
assistantStream: SessionAssistantStreamBaseline = { revision: 0, attempts: [] },
|
||||
assistantStream: SessionAssistantStreamBaseline = { revision: 0 },
|
||||
): SessionFollowFrame {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
header: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
|
||||
createdAt: 0,
|
||||
isSeeded: false,
|
||||
},
|
||||
cursor,
|
||||
records,
|
||||
@@ -154,18 +142,18 @@ describe('Session Client stream adapters', () => {
|
||||
const attemptId = LlmAttemptId('transport-attempt')
|
||||
const baseline: SessionAssistantStreamBaseline = {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [{ type: 'text-delta', index: 0, text: 'a' }],
|
||||
legacyChunkSeqs: [0],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['a'] }],
|
||||
},
|
||||
}
|
||||
const frame: SessionAssistantStreamFrame = {
|
||||
type: 'chunk', attemptId, revision: 3, index: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'b' }, legacyChunkSeq: 1,
|
||||
time: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
|
||||
}
|
||||
const remote = new ScriptedSessionRemote(
|
||||
[{ frames: [snapshot(0, [entry(0)], false, baseline), assistantFrame(frame)], hold: true }],
|
||||
@@ -193,9 +181,10 @@ describe('Session Client stream adapters', () => {
|
||||
frames: [{
|
||||
type: 'snapshot',
|
||||
header: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
id: ADDRESS.sessionId,
|
||||
createdAt: 0,
|
||||
isSeeded: false,
|
||||
},
|
||||
cursor: -1,
|
||||
records: [],
|
||||
@@ -249,19 +238,18 @@ describe('Session Client stream adapters', () => {
|
||||
}
|
||||
const gap: SessionAssistantStreamFrame = {
|
||||
type: 'chunk', attemptId, revision: 3, index: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'lost predecessor' },
|
||||
legacyChunkSeq: 1,
|
||||
time: 1, chunk: { type: 'text-delta', index: 0, text: 'lost predecessor' },
|
||||
}
|
||||
const replacement: SessionAssistantStreamBaseline = {
|
||||
revision: 3,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [gap.chunk],
|
||||
legacyChunkSeqs: [1],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['lost predecessor'] }],
|
||||
},
|
||||
}
|
||||
const remote = new ScriptedSessionRemote([
|
||||
{
|
||||
@@ -297,14 +285,14 @@ describe('Session Client stream adapters', () => {
|
||||
const attemptId = LlmAttemptId('replacement-lifecycle-attempt')
|
||||
const previous: SessionAssistantStreamBaseline = {
|
||||
revision: 2,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunks: [{ type: 'text-delta', index: 0, text: 'old' }],
|
||||
legacyChunkSeqs: [0],
|
||||
}],
|
||||
nextIndex: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['old'] }],
|
||||
},
|
||||
}
|
||||
const replacementStart: SessionAssistantStreamFrame = {
|
||||
type: 'start', attemptId, revision: 1, startedTime: 2,
|
||||
@@ -312,14 +300,14 @@ describe('Session Client stream adapters', () => {
|
||||
}
|
||||
const replacement: SessionAssistantStreamBaseline = {
|
||||
revision: 1,
|
||||
attempts: [{
|
||||
activeAttempt: {
|
||||
attemptId,
|
||||
startedTime: 2,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
chunks: [],
|
||||
legacyChunkSeqs: [],
|
||||
}],
|
||||
nextIndex: 0,
|
||||
stream: [],
|
||||
},
|
||||
}
|
||||
const remote = new ScriptedSessionRemote([
|
||||
{
|
||||
@@ -350,10 +338,9 @@ describe('Session Client stream adapters', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('validates a packed logical range before publishing one compact Client entry', async () => {
|
||||
const row = chunks(1)
|
||||
it('validates one scalar current-event range before publishing Client entries', async () => {
|
||||
const remote = new ScriptedSessionRemote(
|
||||
[{ frames: [snapshot(4, [entry(0), row, entry(4)]), entry(5)], hold: true }],
|
||||
[{ frames: [snapshot(2, [entry(0), entry(1), entry(2)]), entry(3)], hold: true }],
|
||||
[],
|
||||
)
|
||||
const changes: SessionJournalChange[] = []
|
||||
@@ -369,34 +356,11 @@ describe('Session Client stream adapters', () => {
|
||||
type: 'replace',
|
||||
entries: [
|
||||
entry(0),
|
||||
row,
|
||||
entry(4),
|
||||
entry(1),
|
||||
entry(2),
|
||||
],
|
||||
})
|
||||
expect(changes[0]?.type === 'replace' ? changes[0].entries[1] : undefined).toBe(row)
|
||||
expect(changes[1]).toEqual({ type: 'append', entry: entry(5) })
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('rejects a packed record emitted by the live follow path', async () => {
|
||||
const failed = vi.fn()
|
||||
const remote = new ScriptedSessionRemote(
|
||||
[{ frames: [snapshot(-1, []), chunks(0) as SessionFollowFrame], hold: true }],
|
||||
[],
|
||||
)
|
||||
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
|
||||
publish: vi.fn(),
|
||||
failed,
|
||||
})
|
||||
|
||||
await stream.open({})
|
||||
await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
|
||||
const violation: unknown = failed.mock.calls[0]?.[0]
|
||||
expect(isRemoteFailure(violation)).toBe(true)
|
||||
expect(violation).toMatchObject({
|
||||
code: 'gateway/internal',
|
||||
message: 'session live stream emitted a packed history record',
|
||||
})
|
||||
expect(changes[1]).toEqual({ type: 'append', entry: entry(3) })
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user