Merge PR6 (snapshot golden removal + PR5 trace-events) into PR7
This commit is contained in:
@@ -43,7 +43,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un
|
||||
// `session.header.id`, NOT the registry key. Using distinct values here makes
|
||||
// the test fail if a regression matched on the wrong field (a same-value fake
|
||||
// would pass either way — the "hits the line but not the scenario" trap).
|
||||
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 1, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
const dispose = ctx.agents.register(agent)
|
||||
const list = fakeAgentDisposers.get(ctx) ?? []
|
||||
list.push(dispose)
|
||||
@@ -466,7 +466,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
|
||||
// it.
|
||||
const fakeAgent = (sessionId: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 1, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
|
||||
it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -582,7 +582,7 @@ describe('session-cwd routing (per-session workdir)', () => {
|
||||
}
|
||||
// An agent whose session header carries a cwd (what session/new records).
|
||||
const agentInCwd = (cwd: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 1, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
|
||||
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
|
||||
const ctx = await setup()
|
||||
|
||||
@@ -38,8 +38,8 @@ function toError(error: unknown): CodedError {
|
||||
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
|
||||
* (the only option for adapters that can't throw mid-stream, e.g.
|
||||
* library-backed ones). This translates the latter into a thrown step error
|
||||
* so the turn ends error/aborted with a logged `error` event, never as a
|
||||
* normal `completed` assistant message.
|
||||
* so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
|
||||
* never as a normal `completed` assistant message.
|
||||
*
|
||||
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
|
||||
* the switch handles the known terminal-failure kinds and treats every other
|
||||
@@ -322,15 +322,22 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// Set `reason` here so the durable failure is captured before closeTurn
|
||||
// appends turn/end. The step number rides along so the operational error's
|
||||
// location survives in the durable log.
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
// Set the error reason ONLY while the turn is still open — closeTurn appends
|
||||
// turn/end with it. If the turn has already ended (the only way here: a
|
||||
// throwing agent/turn-end listener after closeTurn(true) already appended
|
||||
// turn/end), the reason can no longer affect the durable log, so log the late
|
||||
// throw directly instead — otherwise the listener exception would vanish.
|
||||
if (!turnEnded) {
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
} else {
|
||||
ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`)
|
||||
}
|
||||
try {
|
||||
ctx.emit('agent/error', agent, turn, step, err)
|
||||
} catch {
|
||||
// contained: the error is already captured on `reason`; a throwing
|
||||
// agent/error listener must not prevent the turn from closing.
|
||||
// contained: the error is already captured (on `reason`, or via the logger
|
||||
// above); a throwing agent/error listener must not prevent the turn from
|
||||
// closing.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,7 +616,14 @@ async function runStep(
|
||||
let message: Message = assembler.message()
|
||||
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
|
||||
|
||||
session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) })
|
||||
// Same content-or-usage guard as the max-tokens branch: a step that finishes
|
||||
// with neither assembled content nor usage (e.g. a bare `stop` finish that
|
||||
// streamed nothing) records no assistant/message — an empty-content message
|
||||
// exists only to host usage, and deriveMessages() skips it either way, so
|
||||
// appending one with no usage would be a pure trace-only row.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) })
|
||||
}
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
|
||||
@@ -487,6 +487,25 @@ describe('agent loop', () => {
|
||||
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
|
||||
})
|
||||
|
||||
it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => {
|
||||
// A clean `stop` finish that streamed nothing assembled (no blocks) and
|
||||
// carried no usage chunk has nothing to record: the content-or-usage guard
|
||||
// on the normal step path suppresses a pure trace-only empty assistant/message.
|
||||
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'completed' }])
|
||||
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
|
||||
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
|
||||
})
|
||||
|
||||
it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
|
||||
const callId = CallId('c1')
|
||||
const adapter = new MockAdapter([[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -806,6 +806,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -815,6 +816,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end)
|
||||
expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary
|
||||
expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error
|
||||
// The late throw is also logged directly: failTurn's turn-already-ended
|
||||
// branch warns so a throwing turn-end listener after turn/end never vanishes.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed'))
|
||||
// The whole log is loadable (nothing dropped): a fresh replay sees the turn.
|
||||
const replay = new Session(SessionId('replay'), [...agent.session.events])
|
||||
expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant'])
|
||||
|
||||
@@ -37,7 +37,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points).
|
||||
- `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages.
|
||||
- `session.events`, `session.seq`, `session.id`
|
||||
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bare `Session` construction.
|
||||
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from './types.ts'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts'
|
||||
import { isJsonValue } from './json.ts'
|
||||
|
||||
@@ -79,9 +79,10 @@ export class Session {
|
||||
/**
|
||||
* Immutable creation metadata (format version, cwd, lineage). Supplied by
|
||||
* the store via `ctx.sessions.create()`. When a `Session` is constructed
|
||||
* bare (tests, ad-hoc replay), a minimal v1 header is synthesized so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
* storage concern, not replayable conversation state.
|
||||
* bare (tests, ad-hoc replay), a minimal header is synthesized (stamped with
|
||||
* the current {@link SESSION_FORMAT_VERSION}) so `session.header` is always
|
||||
* present. Kept out of the event log — it is a storage concern, not
|
||||
* replayable conversation state.
|
||||
*/
|
||||
readonly header: SessionHeader
|
||||
|
||||
@@ -112,7 +113,7 @@ export class Session {
|
||||
// structuredClone can never hit a non-cloneable value here.
|
||||
this.log = seed.map(event => structuredClone(event))
|
||||
}
|
||||
this.header = header ?? { version: 1, id, createdAt: Date.now() }
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
get events(): readonly SessionEvent[] {
|
||||
@@ -180,8 +181,7 @@ export class Session {
|
||||
const messages: Message[] = []
|
||||
for (const event of this.log) {
|
||||
// Intentionally non-exhaustive: only message-producing events derive
|
||||
// history; turn/step boundaries, chunks, usage, and errors are
|
||||
// trace/replay data.
|
||||
// history; turn/step boundaries and chunks are trace/replay data.
|
||||
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
@@ -284,7 +284,7 @@ export class SessionStore extends Service {
|
||||
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
|
||||
}
|
||||
const header: SessionHeader = {
|
||||
version: 1,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: sessionId,
|
||||
createdAt: options?.meta?.createdAt ?? Date.now(),
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
|
||||
@@ -9,6 +9,23 @@ export function SessionId(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-disk session format version, stamped into every newly-written
|
||||
* {@link SessionHeader} and enforced by every persistence backend on load. The
|
||||
* single source of truth for the version — write sites and the load-time check
|
||||
* all read it.
|
||||
*
|
||||
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
|
||||
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
|
||||
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
|
||||
* removing a variant, …) happen freely and do NOT bump this — v0 absorbs all
|
||||
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
|
||||
* migration; no persisted user data exists to preserve). A real, monotonically
|
||||
* bumped version policy begins at the first tagged release, when a specific
|
||||
* format boundary becomes worth distinguishing.
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
/**
|
||||
* Immutable session metadata — written once at creation and never rewritten.
|
||||
*
|
||||
@@ -19,7 +36,11 @@ export function SessionId(id: string): SessionId {
|
||||
* metadata) writes such a header.
|
||||
*/
|
||||
export interface SessionHeader {
|
||||
/** On-disk format version; a persistence backend rejects unknown versions. */
|
||||
/**
|
||||
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
|
||||
* session is created. A persistence backend rejects any other version on load
|
||||
* (no migration — see the constant).
|
||||
*/
|
||||
version: number
|
||||
/** The session's id (mirrors the {@link Session}'s id). */
|
||||
id: SessionId
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('derives message history from the event log', () => {
|
||||
@@ -255,11 +255,11 @@ describe('SessionStore', () => {
|
||||
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('synthesizes a minimal v1 header for a bare-created session', async () => {
|
||||
it('synthesizes a minimal current-version header for a bare-created session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('plain'))
|
||||
expect(session.header).toMatchObject({ version: 1, id: 'plain' })
|
||||
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
expect(session.header.cwd).toBeUndefined()
|
||||
expect(session.header.parentSession).toBeUndefined()
|
||||
@@ -272,7 +272,7 @@ describe('SessionStore', () => {
|
||||
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
|
||||
})
|
||||
expect(session.header).toMatchObject({
|
||||
version: 1,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'child',
|
||||
cwd: '/work/project',
|
||||
parentSession: 'parent',
|
||||
@@ -288,9 +288,9 @@ describe('SessionStore', () => {
|
||||
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a bare Session() constructed without the store still exposes a v1 header', () => {
|
||||
it('a bare Session() constructed without the store still exposes a current-version header', () => {
|
||||
const session = new Session(SessionId('bare'))
|
||||
expect(session.header).toMatchObject({ version: 1, id: 'bare' })
|
||||
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`.
|
||||
- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md).
|
||||
- **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
|
||||
- **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve).
|
||||
- **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve).
|
||||
|
||||
## Write path
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
|
||||
it('path-traversal session ids are neutralized (no escape from root)', async () => {
|
||||
const evil = SessionId('../../etc/pwn')
|
||||
const m = { version: 1, id: evil, createdAt: 1 }
|
||||
const m = { version: 0, id: evil, createdAt: 1 }
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(evil, oneTurnLog())
|
||||
// The file lives UNDER root, not at ../../etc.
|
||||
@@ -310,7 +310,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
|
||||
it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 1, id: 'g', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
|
||||
].join('\n') + '\n'
|
||||
@@ -323,7 +323,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
|
||||
it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 1, id: 'g2', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
|
||||
JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
@@ -335,7 +335,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
|
||||
it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 1, id: 'c', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1 }),
|
||||
'{not json', // corrupt, sits in the committed region (a turn/end follows)
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
@@ -343,7 +343,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
})
|
||||
|
||||
it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
|
||||
const log = JSON.stringify({ type: 'session', version: 1, id: 'h0', createdAt: 1 }) + '\n'
|
||||
const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1 }) + '\n'
|
||||
const scanned = scanLog(Buffer.from(log))
|
||||
expect(scanned.events).toEqual([])
|
||||
// committedBytes falls back to the header line's end (no preserved events).
|
||||
@@ -352,7 +352,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
|
||||
it('a corrupt line after the last turn/end bounds the preserved tail', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 1, id: 'c2', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
'{not json', // corrupt crash fragment, no turn/end committed
|
||||
].join('\n') + '\n'
|
||||
@@ -363,7 +363,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
|
||||
it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 1, id: 't', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
|
||||
@@ -442,7 +442,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
// field is tolerated by the header type guard) and confirm list() reads it.
|
||||
const bucket = join(root, '_no-cwd')
|
||||
await mkdir(bucket, { recursive: true })
|
||||
const bigHeader = JSON.stringify({ type: 'session', version: 1, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) })
|
||||
const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) })
|
||||
await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n')
|
||||
const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
|
||||
expect(ids).toContain('big')
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { interruptedTurnClosers } from '@deepseek-ai/dsh-session'
|
||||
import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { assertSerializable, seedCoversPrefix } from './index.ts'
|
||||
|
||||
@@ -319,8 +319,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
|
||||
private assertVersion(meta: SessionHeader): void {
|
||||
if (meta.version !== 1) {
|
||||
throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`)
|
||||
if (meta.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
@@ -23,7 +23,7 @@ export interface ContractBackend {
|
||||
/** Build a minimal {@link SessionHeader} for a session id. */
|
||||
export function meta(id: string, cwd?: string): SessionHeader {
|
||||
return {
|
||||
version: 1,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId(id),
|
||||
createdAt: 1000,
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
@@ -57,7 +57,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
expect(loaded.meta).toMatchObject({ version: 1, id: m.id, cwd: '/work' })
|
||||
expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
|
||||
expect(loaded.events).toEqual(log)
|
||||
} finally {
|
||||
await dispose()
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
import { meta, oneTurnLog } from './contract.ts'
|
||||
@@ -671,7 +671,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const m = { version: 2, id: SessionId('v2'), createdAt: 1, cwd: WORK }
|
||||
const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK }
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/)
|
||||
@@ -685,7 +685,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const m = { version: 1, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') }
|
||||
const m = { version: SESSION_FORMAT_VERSION, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') }
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const loaded = await ctx.sessionPersistence.load(m.id)
|
||||
|
||||
@@ -33,7 +33,7 @@ const TEXT_CHUNKS: StreamChunk[] = [
|
||||
|
||||
/** Build a minimal session-JSONL string: a header line + the given events. */
|
||||
function sessionJsonl(events: SessionEvent[]): string {
|
||||
const header = JSON.stringify({ type: 'session', version: 1, id: 's1', createdAt: 0 })
|
||||
const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
|
||||
return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n'
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('parseSessionLog', () => {
|
||||
})
|
||||
|
||||
it('ignores blank lines', () => {
|
||||
const header = JSON.stringify({ type: 'session', version: 1, id: 's1', createdAt: 0 })
|
||||
const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
|
||||
const ev = chunkEvent(1, 1, 1, TEXT_CHUNKS[0] as StreamChunk)
|
||||
expect(parseSessionLog(`${header}\n\n${JSON.stringify(ev)}\n\n`)).toEqual([ev])
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
|
||||
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness HAS usage events internally). |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
|
||||
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
|
||||
|
||||
## 5. Tool-call rendering
|
||||
@@ -148,7 +148,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
|
||||
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
8. **Diff + location tool rendering** — `diff` content and `locations` for edit tools.
|
||||
9. **Usage reporting** (`usage_update`) — the harness already has the internal usage events.
|
||||
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const otherCwd = '/some/other/workspace'
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: 1, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd,
|
||||
version: SESSION_FORMAT_VERSION, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd,
|
||||
})
|
||||
await loader.ctx.sessionPersistence.append(SessionId('elsewhere'), [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
@@ -204,7 +204,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
// to the server's launch dir (the request cwd does not override the header).
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: 1, id: SessionId('legacy'), createdAt: 1, // no cwd
|
||||
version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd
|
||||
})
|
||||
await loader.ctx.sessionPersistence.append(SessionId('legacy'), [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
|
||||
Reference in New Issue
Block a user