# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
174 lines
7.8 KiB
TypeScript
174 lines
7.8 KiB
TypeScript
/** Request-header canonicalization, equality, snapshot folding, and format rejection. */
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
|
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
|
|
|
const CONFIG = { provider: 'mock', model: 'm' }
|
|
|
|
function tool(name: string, description = 'd'): ToolSchema {
|
|
return { name, description, parameters: { type: 'object' } }
|
|
}
|
|
|
|
describe('canonicalHeader', () => {
|
|
it('normalizes empty optional fields to absence and preserves populated fields', () => {
|
|
expect(canonicalHeader({
|
|
config: CONFIG,
|
|
adapterDefaults: {},
|
|
system: '',
|
|
tools: [],
|
|
})).toEqual({ config: CONFIG })
|
|
const full = canonicalHeader({
|
|
config: { ...CONFIG, maxTokens: 256_000 },
|
|
adapterDefaults: { maxTokens: true },
|
|
system: 's',
|
|
tools: [tool('a')],
|
|
})
|
|
expect(full).toEqual({
|
|
config: { ...CONFIG, maxTokens: 256_000 },
|
|
adapterDefaults: { maxTokens: true },
|
|
system: 's',
|
|
tools: [tool('a')],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('headerEquals', () => {
|
|
const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
|
|
|
|
it('compares every canonical field and preserves tool order', () => {
|
|
expect(headerEquals(base, structuredClone(base))).toBe(true)
|
|
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
|
|
expect(headerEquals(base, {
|
|
...base,
|
|
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
|
|
})).toBe(false)
|
|
expect(headerEquals(
|
|
{ ...base, config: { ...base.config, maxTokens: 256_000 } },
|
|
{
|
|
...base,
|
|
config: { ...base.config, maxTokens: 256_000 },
|
|
adapterDefaults: { maxTokens: true },
|
|
},
|
|
)).toBe(false)
|
|
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
|
|
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
|
|
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false)
|
|
expect(headerEquals({ config: CONFIG, tools: [tool('a'), tool('b')] }, { config: CONFIG, tools: [tool('b'), tool('a')] })).toBe(false)
|
|
})
|
|
|
|
it('treats absent and empty tool arrays as equivalent canonical absence', () => {
|
|
expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [] })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('foldRequestHeader', () => {
|
|
it('returns the supplied baseline when no snapshot follows', () => {
|
|
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
|
|
const unrelated: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
|
]
|
|
expect(foldRequestHeader(unrelated)).toBeUndefined()
|
|
expect(foldRequestHeader(unrelated, from)).toBe(from)
|
|
})
|
|
|
|
it('takes the latest full snapshot and skips unrelated events', () => {
|
|
const session = new Session(SessionId('fold'))
|
|
session.append('turn/start', { turn: 1 })
|
|
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
|
}), { surfaceOp: 'append' })
|
|
session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' })
|
|
expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } })
|
|
})
|
|
})
|
|
|
|
describe('legacy request-header format', () => {
|
|
it('rejects request/header-delta in seeds and untyped appends', () => {
|
|
const legacy = [{
|
|
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
|
|
}] as unknown as SessionEvent[]
|
|
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
|
|
|
|
const session = new Session(SessionId('legacy-append-delta'))
|
|
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
|
expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
|
|
.toThrow(/unsupported legacy request\/header-delta/)
|
|
expect(session.events).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects the removed fallback reason in seeds and untyped appends', () => {
|
|
const legacy = [{
|
|
type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
|
|
}] as unknown as SessionEvent[]
|
|
expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
|
|
.toThrow('unsupported legacy request/header reason "fallback"')
|
|
|
|
const session = new Session(SessionId('legacy-append-reason'))
|
|
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
|
expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
|
|
.toThrow('unsupported legacy request/header reason "fallback"')
|
|
expect(session.events).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Session.requestContext', () => {
|
|
const CAPACITY = { provider: 'mock', model: 'm', contextWindow: 128_000 }
|
|
|
|
/** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
|
|
function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
|
|
const events: SessionEvent[] = [{
|
|
type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
}]
|
|
for (const data of records) {
|
|
events.push({ type: 'request/context', seq: events.length, time: 1, data })
|
|
}
|
|
return events
|
|
}
|
|
|
|
it('reads undefined before any record exists', () => {
|
|
expect(new Session(SessionId('no-capacity')).requestContext()).toBeUndefined()
|
|
})
|
|
|
|
it('folds a seeded log on first read, taking the last record', () => {
|
|
// The fold watermark starts at 0 with the seed already in the log, so the
|
|
// first read must consume the whole seed rather than skip it.
|
|
const session = new Session(SessionId('seeded-capacity'), seedWith(
|
|
CAPACITY,
|
|
{ ...CAPACITY, model: 'later', contextWindow: 256_000 },
|
|
))
|
|
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'later', contextWindow: 256_000 })
|
|
})
|
|
|
|
it('advances incrementally across appends and skips unrelated events', () => {
|
|
const session = new Session(SessionId('incremental-capacity'), seedWith(CAPACITY))
|
|
expect(session.requestContext()).toEqual(CAPACITY)
|
|
session.append('todo/write', { todos: [] })
|
|
expect(session.requestContext()).toEqual(CAPACITY)
|
|
session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
|
|
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
|
|
session.append('request/context', { provider: 'mock', model: 'unknown' })
|
|
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'unknown' })
|
|
})
|
|
|
|
it('folds a batch appended between two reads', () => {
|
|
const session = new Session(SessionId('batched-capacity'), seedWith(CAPACITY))
|
|
expect(session.requestContext()).toEqual(CAPACITY)
|
|
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
|
|
session.append('todo/write', { todos: [] })
|
|
session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
|
|
expect(session.requestContext()?.contextWindow).toBe(300_000)
|
|
})
|
|
|
|
it('exposes a frozen record so a reader cannot desync later comparisons', () => {
|
|
const session = new Session(SessionId('frozen-capacity'), seedWith(CAPACITY))
|
|
const held = session.requestContext()
|
|
if (held === undefined) throw new Error('expected a folded capacity record')
|
|
expect(Object.isFrozen(held)).toBe(true)
|
|
expect(() => { (held as { contextWindow?: number }).contextWindow = 1 }).toThrow()
|
|
})
|
|
})
|