2026-07-19 11:36:07 +08:00
|
|
|
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
import { Context } from 'cordis'
|
2026-07-25 07:47:51 +08:00
|
|
|
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
2026-07-21 15:49:37 +08:00
|
|
|
import SessionStore, {
|
2026-07-22 17:34:31 +08:00
|
|
|
displayPromptContent,
|
2026-07-21 15:49:37 +08:00
|
|
|
findLastMessageTurnEnd,
|
|
|
|
|
SESSION_FORMAT_VERSION,
|
|
|
|
|
Session,
|
|
|
|
|
SessionEvent,
|
|
|
|
|
SessionId,
|
|
|
|
|
} from '@deepseek-ai/dsh-session'
|
2026-07-19 11:36:07 +08:00
|
|
|
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
describe('Session', () => {
|
2026-07-19 11:36:07 +08:00
|
|
|
it('exposes one stable readonly surface view', () => {
|
|
|
|
|
const session = new Session(SessionId('surface-view'))
|
|
|
|
|
const surface = session.surface
|
|
|
|
|
|
|
|
|
|
expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
|
|
|
|
|
expect(surface).toBe(session.surface)
|
|
|
|
|
})
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
it('derives message history from the event log', () => {
|
2026-06-11 15:17:56 +08:00
|
|
|
const session = new Session(SessionId('s1'))
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-06-23 13:05:59 +08:00
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
2026-07-14 21:57:52 +08:00
|
|
|
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
turn: 1, step: 1,
|
|
|
|
|
content: [
|
|
|
|
|
{ type: 'text', text: 'let me check' },
|
2026-06-11 15:17:56 +08:00
|
|
|
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
],
|
2026-06-23 13:05:59 +08:00
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
|
|
|
|
|
|
const messages = session.deriveMessages()
|
|
|
|
|
expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
|
|
|
|
|
// raw chunks must NOT appear in derived history
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
expect(messages[1]!.content).toHaveLength(2)
|
2026-06-11 15:17:56 +08:00
|
|
|
expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-15 23:53:47 +08:00
|
|
|
it('accepts and round-trips a max-tokens turn/end reason', () => {
|
|
|
|
|
// The max-tokens TurnEndReason variant carries no extra data, so it must
|
|
|
|
|
// append and persist like any other reason (JSON-serializable, no fields).
|
|
|
|
|
const session = new Session(SessionId('s1'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
|
|
|
|
|
|
|
|
|
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
|
|
|
|
|
expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
|
|
|
|
|
// survives a structuredClone (the persistence-serialization boundary)
|
|
|
|
|
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 15:49:37 +08:00
|
|
|
it('finds the latest message-turn outcome past later non-message turns', () => {
|
|
|
|
|
const session = new Session(SessionId('message-turn-outcome'))
|
|
|
|
|
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
|
|
|
|
session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
|
|
|
|
})
|
2026-07-23 19:15:45 +08:00
|
|
|
session.append('user/message', {
|
2026-07-21 15:49:37 +08:00
|
|
|
content: [{ type: 'text', text: 'before' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'before' },
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
|
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
session.append('turn/start', {
|
|
|
|
|
turn: 2,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})
|
|
|
|
|
session.append('user/message', {
|
|
|
|
|
content: [{ type: 'text', text: 'bounded prompt' }],
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
|
|
|
|
session.append('turn/start', {
|
|
|
|
|
turn: 3,
|
|
|
|
|
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
|
|
|
|
|
})
|
2026-07-23 19:15:45 +08:00
|
|
|
session.append('user/message', {
|
2026-07-21 15:49:37 +08:00
|
|
|
content: [{ type: 'text', text: 'after' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'after' },
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
|
|
|
|
|
|
|
|
|
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-16 18:12:34 +08:00
|
|
|
it('round-trips the coarse aborted turn outcome', () => {
|
|
|
|
|
const session = new Session(SessionId('aborted'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
|
|
|
|
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
|
|
|
|
expect(replayed.events).toEqual(session.events)
|
|
|
|
|
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
|
|
|
|
|
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 12:14:53 +08:00
|
|
|
it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => {
|
|
|
|
|
const legacy = [
|
|
|
|
|
{
|
|
|
|
|
type: 'turn/start', seq: 0, time: 1,
|
|
|
|
|
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'turn/end', seq: 1, time: 2,
|
|
|
|
|
data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } },
|
|
|
|
|
},
|
|
|
|
|
] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
expect(() => new Session(SessionId('legacy-aborted'), legacy))
|
|
|
|
|
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-23 19:15:45 +08:00
|
|
|
it('renders injected-context and steering messages as plain user content', () => {
|
2026-07-25 07:47:51 +08:00
|
|
|
expect(displayPromptContent({
|
|
|
|
|
content: [{ type: 'text', text: 'plain prompt' }],
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
})).toEqual([{ type: 'text', text: 'plain prompt' }])
|
|
|
|
|
|
2026-06-11 15:17:56 +08:00
|
|
|
const session = new Session(SessionId('s2'))
|
2026-07-23 19:15:45 +08:00
|
|
|
session.append('user/message', {
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'watcher' },
|
2026-06-23 13:05:59 +08:00
|
|
|
}, { surfaceOp: 'append' })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
session.append('steering/message', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
content: [{ type: 'text', text: 'focus on tests' }],
|
|
|
|
|
source: { kind: 'user' },
|
2026-06-23 13:05:59 +08:00
|
|
|
}, { surfaceOp: 'append' })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
const [contextMessage, steeringMessage] = session.deriveMessages()
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
expect(contextMessage!.role).toBe('user')
|
2026-07-20 14:37:04 +08:00
|
|
|
expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }])
|
2026-07-20 11:00:36 +08:00
|
|
|
expect(steeringMessage!.role).toBe('user')
|
|
|
|
|
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-22 17:34:31 +08:00
|
|
|
it('derives baked prompt context while exposing only the direct prompt for display', () => {
|
|
|
|
|
const session = new Session(SessionId('prompt-envelope'))
|
|
|
|
|
const event = session.append('user/message', {
|
|
|
|
|
content: [
|
|
|
|
|
{ type: 'text', text: 'background' },
|
|
|
|
|
{ type: 'text', text: '\n\n## My request:\n' },
|
|
|
|
|
{ type: 'text', text: 'question' },
|
|
|
|
|
],
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
envelope: {
|
|
|
|
|
displayContent: [{ type: 'text', text: 'question' }],
|
|
|
|
|
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' }, meta: { kind: 'card' } }],
|
|
|
|
|
},
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
|
|
|
|
|
expect(session.deriveMessages()).toEqual([{
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: [
|
|
|
|
|
{ type: 'text', text: 'background' },
|
|
|
|
|
{ type: 'text', text: '\n\n## My request:\n' },
|
|
|
|
|
{ type: 'text', text: 'question' },
|
|
|
|
|
],
|
|
|
|
|
}])
|
|
|
|
|
expect(displayPromptContent(event.data)).toEqual([{ type: 'text', text: 'question' }])
|
|
|
|
|
expect(Object.isFrozen(event.data.envelope?.displayContent)).toBe(true)
|
|
|
|
|
expect(new Session(SessionId('prompt-envelope-replay'), session.events).deriveMessages())
|
|
|
|
|
.toEqual(session.deriveMessages())
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-20 14:37:04 +08:00
|
|
|
it('keeps context meta durable in the event while hiding it from the projection', () => {
|
2026-07-10 14:32:44 +08:00
|
|
|
const session = new Session(SessionId('s2-raw'))
|
|
|
|
|
const meta = {
|
|
|
|
|
kind: 'workspace-instructions',
|
|
|
|
|
version: 1,
|
|
|
|
|
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
|
|
|
|
|
}
|
2026-07-23 19:15:45 +08:00
|
|
|
session.append('user/message', {
|
2026-07-10 14:32:44 +08:00
|
|
|
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'workspace-context' },
|
|
|
|
|
meta,
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
|
|
|
|
|
expect(session.deriveMessages()).toEqual([{
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
|
|
|
|
}])
|
|
|
|
|
const event = session.events[0]
|
2026-07-23 19:15:45 +08:00
|
|
|
expect(event?.type === 'user/message' && event.data.meta).toEqual(meta)
|
2026-07-10 14:32:44 +08:00
|
|
|
})
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
it('replays identically from a seeded event log', () => {
|
2026-06-11 15:17:56 +08:00
|
|
|
const original = new Session(SessionId('s3'))
|
2026-07-06 14:17:31 +08:00
|
|
|
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-06-23 13:05:59 +08:00
|
|
|
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
2026-07-14 21:57:52 +08:00
|
|
|
original.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
|
2026-07-06 14:17:31 +08:00
|
|
|
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-06-11 15:17:56 +08:00
|
|
|
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
|
|
|
|
expect(replayed.seq).toBe(original.seq)
|
|
|
|
|
})
|
2026-06-13 23:25:12 +08:00
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
|
|
|
|
|
const requestHeader = {
|
|
|
|
|
type: 'request/header', seq: 0, time: 1,
|
|
|
|
|
data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
|
|
|
|
|
} as unknown as SessionEvent
|
|
|
|
|
expect(() => new Session(SessionId('old-header'), [requestHeader]))
|
|
|
|
|
.toThrow('seed request/header at index 0 lacks provider/model')
|
|
|
|
|
|
|
|
|
|
const assistantMessage = {
|
|
|
|
|
type: 'assistant/message', seq: 0, time: 1,
|
|
|
|
|
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
|
|
|
|
|
surfaceOp: 'append',
|
|
|
|
|
} as unknown as SessionEvent
|
|
|
|
|
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
|
|
|
|
|
.toThrow('seed assistant/message at index 0 lacks provider/model provenance')
|
|
|
|
|
|
|
|
|
|
const malformedHeader = {
|
|
|
|
|
type: 'request/header', seq: 0, time: 1,
|
|
|
|
|
data: { header: 'old-header' },
|
|
|
|
|
} as unknown as SessionEvent
|
|
|
|
|
expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
|
|
|
|
|
.toThrow('seed request/header at index 0 lacks provider/model')
|
|
|
|
|
|
|
|
|
|
const unrelatedPrimitiveData = {
|
|
|
|
|
type: 'plugin/event', seq: 0, time: 1, data: null,
|
|
|
|
|
} as unknown as SessionEvent
|
|
|
|
|
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events)
|
|
|
|
|
.toEqual([unrelatedPrimitiveData])
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
2026-06-13 23:25:12 +08:00
|
|
|
|
2026-07-25 07:47:51 +08:00
|
|
|
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
|
|
|
|
|
const valid = {
|
|
|
|
|
type: 'request/header',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: {
|
|
|
|
|
header: {
|
|
|
|
|
config: {
|
|
|
|
|
provider: 'mock',
|
|
|
|
|
model: 'model',
|
|
|
|
|
reasoningEffort: ReasoningEffortId('adapter-owned'),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
reason: 'initial',
|
|
|
|
|
},
|
|
|
|
|
} as const
|
|
|
|
|
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
|
|
|
|
|
.toEqual(valid)
|
|
|
|
|
|
|
|
|
|
for (const reasoningEffort of ['', 1]) {
|
|
|
|
|
const invalid = structuredClone(valid) as unknown as SessionEvent
|
|
|
|
|
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
|
|
|
|
const config = invalid.data.header.config as unknown as Record<string, unknown>
|
|
|
|
|
config.reasoningEffort = reasoningEffort
|
|
|
|
|
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
|
|
|
|
|
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-13 23:25:12 +08:00
|
|
|
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
|
|
|
|
const session = new Session(SessionId('s4'))
|
2026-06-23 13:05:59 +08:00
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
2026-06-13 23:25:12 +08:00
|
|
|
session.append('tool/result', {
|
|
|
|
|
turn: 1, step: 1, callId: CallId('c1'),
|
|
|
|
|
content: [{ type: 'text', text: 'tool out' }], isError: false,
|
2026-06-23 13:05:59 +08:00
|
|
|
}, { surfaceOp: 'append' })
|
2026-06-13 23:25:12 +08:00
|
|
|
const before = structuredClone(session.events)
|
|
|
|
|
|
2026-07-06 02:51:20 +08:00
|
|
|
// A misbehaving consumer tries to mutate the messages it was handed.
|
2026-06-13 23:25:12 +08:00
|
|
|
const messages = session.deriveMessages()
|
|
|
|
|
const userBlock = messages[0]!.content[0]!
|
2026-07-06 02:51:20 +08:00
|
|
|
expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
|
2026-06-13 23:25:12 +08:00
|
|
|
const toolBlock = messages[1]!.content[0]!
|
2026-07-06 02:51:20 +08:00
|
|
|
expect(() => {
|
|
|
|
|
if (toolBlock.type === 'tool-result') toolBlock.content.push({ type: 'text', text: 'injected' })
|
|
|
|
|
}).toThrow(TypeError)
|
|
|
|
|
expect(() => { messages[0]!.content.push({ type: 'text', text: 'extra' }) }).toThrow(TypeError)
|
|
|
|
|
// The returned ARRAY is the caller's own snapshot, though — reordering it
|
|
|
|
|
// is the caller's business and never reaches the cache or the log.
|
|
|
|
|
messages.reverse()
|
2026-06-13 23:25:12 +08:00
|
|
|
|
|
|
|
|
// The log is unchanged: deep-equal to the snapshot taken before mutation.
|
|
|
|
|
expect(session.events).toEqual(before)
|
2026-07-06 02:51:20 +08:00
|
|
|
// And a fresh derivation still reflects the original content and order.
|
2026-06-13 23:25:12 +08:00
|
|
|
expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
|
|
|
|
|
})
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
|
|
|
|
|
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
|
|
|
|
|
const session = new Session(SessionId('s5'))
|
2026-06-23 13:05:59 +08:00
|
|
|
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
expect(bad(1n)).toThrow(/non-JSON-serializable/)
|
|
|
|
|
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
|
|
|
|
|
expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
|
|
|
|
|
expect(bad(new Map())).toThrow(/non-JSON-serializable/)
|
|
|
|
|
expect(bad(undefined)).toThrow(/non-JSON-serializable/)
|
|
|
|
|
expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
|
|
|
|
|
// A sparse array: `every` skips the hole but JSON.stringify writes it null.
|
|
|
|
|
// Build the hole without a sparse literal or `delete` (both linted).
|
|
|
|
|
const sparse: unknown[] = Array(3)
|
|
|
|
|
sparse[0] = 1
|
|
|
|
|
sparse[2] = 3 // index 1 stays a hole
|
|
|
|
|
expect(bad(sparse)).toThrow(/non-JSON-serializable/)
|
|
|
|
|
// A DENSE array carrying a non-serializable element is rejected too.
|
|
|
|
|
expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
|
|
|
|
|
// A nested non-serializable value (inside a plain object) is rejected.
|
|
|
|
|
expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
|
|
|
|
|
// A circular reference is rejected (the seen-set guard, not a stack blow-up).
|
|
|
|
|
const cyclic: Record<string, unknown> = { a: 1 }
|
|
|
|
|
cyclic['self'] = cyclic
|
|
|
|
|
expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
|
|
|
|
|
// The rejected appends never entered the log.
|
|
|
|
|
expect(session.events).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-24 17:45:48 +08:00
|
|
|
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
|
|
|
|
const session = new Session(SessionId('s5b'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-07-13 23:27:00 +08:00
|
|
|
// A widened SessionEventType bypasses the overload's conditional requirement,
|
|
|
|
|
// so the runtime guard must still reject the missing surface marker.
|
2026-06-24 17:45:48 +08:00
|
|
|
const widenedType = 'user/message' as SessionEventType
|
|
|
|
|
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
|
|
|
|
.toThrow(/surface-eligible and requires a surfaceOp marker/)
|
|
|
|
|
// The rejected append never entered the log (only turn/start is present).
|
|
|
|
|
expect(session.events).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
it('accepts dense arrays and nested plain objects', () => {
|
|
|
|
|
const session = new Session(SessionId('s6'))
|
2026-06-23 13:05:59 +08:00
|
|
|
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
expect(session.events).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('validates seed events: rejects a non-JSON-serializable seed', () => {
|
|
|
|
|
// A replay/fork seed must satisfy the SAME invariant as Session.append, or
|
|
|
|
|
// it builds a live log no backend can persist.
|
|
|
|
|
const badSeed = [
|
|
|
|
|
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
|
|
|
|
|
] as unknown as SessionEvent[]
|
2026-07-12 03:51:55 +08:00
|
|
|
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('validates seed events: rejects a non-contiguous seq', () => {
|
|
|
|
|
const gapSeed = [
|
|
|
|
|
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
|
|
|
|
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
|
|
|
|
|
] as SessionEvent[]
|
|
|
|
|
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-24 17:45:48 +08:00
|
|
|
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
|
|
|
|
|
// A surface-eligible event (user/message) with no surfaceOp would load fine
|
|
|
|
|
// but vanish from deriveMessages() (the surface is the sole derivation path),
|
|
|
|
|
// so a resume/fork would silently lose history. append() forbids this at
|
|
|
|
|
// compile time; a raw seed must be rejected at runtime to match.
|
|
|
|
|
const markerlessSeed = [
|
|
|
|
|
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
|
|
|
|
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
|
|
|
|
|
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
|
|
|
|
] as SessionEvent[]
|
2026-07-12 03:51:55 +08:00
|
|
|
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
2026-06-24 17:45:48 +08:00
|
|
|
})
|
|
|
|
|
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
it('accepts a well-formed contiguous serializable seed', () => {
|
|
|
|
|
const goodSeed = [
|
|
|
|
|
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
2026-06-24 17:45:48 +08:00
|
|
|
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
|
|
|
|
] as SessionEvent[]
|
|
|
|
|
const session = new Session(SessionId('seed-ok'), goodSeed)
|
|
|
|
|
expect(session.events).toHaveLength(3)
|
|
|
|
|
})
|
2026-06-15 23:33:00 +08:00
|
|
|
|
2026-07-12 03:51:55 +08:00
|
|
|
it('reads each seed array entry once so validation and storage use the same event', () => {
|
|
|
|
|
const accepted = {
|
|
|
|
|
type: 'turn/start' as const,
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
|
|
|
|
}
|
|
|
|
|
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
|
|
|
|
|
let reads = 0
|
|
|
|
|
const seed = new Array<SessionEvent>(1)
|
|
|
|
|
Object.defineProperty(seed, 0, {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => {
|
|
|
|
|
reads += 1
|
|
|
|
|
return reads === 1 ? accepted : drifted
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const session = new Session(SessionId('seed-entry-snapshot'), seed)
|
|
|
|
|
|
|
|
|
|
expect(reads).toBe(1)
|
|
|
|
|
expect(session.events).toEqual([accepted])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reads a nested seed-data getter once and stores its first JSON value', () => {
|
|
|
|
|
let reads = 0
|
|
|
|
|
const data = Object.defineProperty({}, 'value', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => {
|
|
|
|
|
reads += 1
|
|
|
|
|
return reads === 1 ? 'accepted' : 1n
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
const session = new Session(SessionId('seed-nested-drift'), seed)
|
|
|
|
|
|
|
|
|
|
expect(reads).toBe(1)
|
|
|
|
|
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects non-JSON surface metadata in a seed event', () => {
|
|
|
|
|
const seed = [{
|
|
|
|
|
type: 'user/message',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
|
|
|
|
surfaceOp: { op: 'replace', start: 1n, end: 2 },
|
|
|
|
|
}] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
|
|
|
|
|
.toThrow(/losslessly JSON-serializable/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects exotic seed metadata before cloning can erase its prototype', () => {
|
|
|
|
|
class ReplaceOp {
|
|
|
|
|
readonly op = 'replace' as const
|
|
|
|
|
readonly start = 0
|
|
|
|
|
readonly end = 0
|
|
|
|
|
}
|
|
|
|
|
const seed = [{
|
|
|
|
|
type: 'user/message',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
|
|
|
|
surfaceOp: new ReplaceOp(),
|
|
|
|
|
}] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
|
|
|
|
|
.toThrow(/losslessly JSON-serializable/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects an exotic seed event shell before spreading erases its prototype', () => {
|
|
|
|
|
class SeedEvent {
|
|
|
|
|
readonly type = 'turn/start' as const
|
|
|
|
|
readonly seq = 0
|
|
|
|
|
readonly time = 1
|
|
|
|
|
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
|
|
|
|
|
}
|
|
|
|
|
const seed: SessionEvent[] = [new SeedEvent()]
|
|
|
|
|
|
|
|
|
|
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
|
2026-07-12 22:36:04 +08:00
|
|
|
.toThrow(/not losslessly JSON-serializable/)
|
2026-07-12 03:51:55 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
|
|
|
|
|
const event = Object.assign(Object.create(null) as Record<string, unknown>, {
|
|
|
|
|
type: 'turn/start' as const,
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
|
|
|
|
}) as unknown as SessionEvent
|
|
|
|
|
|
|
|
|
|
const session = new Session(SessionId('seed-null-prototype'), [event])
|
|
|
|
|
|
|
|
|
|
expect(session.events).toEqual([{ ...event }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
|
|
|
|
|
let reads = 0
|
|
|
|
|
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => {
|
|
|
|
|
reads += 1
|
|
|
|
|
return reads === 1 ? 0 : 1n
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const seed = [{
|
|
|
|
|
type: 'user/message',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
2026-07-14 13:49:36 +08:00
|
|
|
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
|
|
|
|
surfaceOp: 'append',
|
|
|
|
|
}, {
|
|
|
|
|
type: 'user/message',
|
|
|
|
|
seq: 1,
|
|
|
|
|
time: 2,
|
2026-07-12 03:51:55 +08:00
|
|
|
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
|
|
|
|
surfaceOp,
|
2026-07-14 13:49:36 +08:00
|
|
|
sourceEventSeqs: [0],
|
2026-07-12 03:51:55 +08:00
|
|
|
}] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
const session = new Session(SessionId('seed-unstable-metadata'), seed)
|
2026-07-14 13:49:36 +08:00
|
|
|
const event = session.events[1]!
|
2026-07-12 03:51:55 +08:00
|
|
|
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
|
|
|
|
|
|
|
|
|
|
expect(reads).toBe(1)
|
|
|
|
|
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 16:05:11 +08:00
|
|
|
it.each([
|
|
|
|
|
['an Error', new Error('validator failed'), 'validator failed'],
|
|
|
|
|
['a non-Error value', 'validator failed', 'invalid surface metadata'],
|
|
|
|
|
] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => {
|
2026-07-12 03:51:55 +08:00
|
|
|
const originalHasOwn = Object.hasOwn
|
|
|
|
|
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
|
2026-07-13 16:05:11 +08:00
|
|
|
if ((object as Record<string, unknown>)['op'] === 'replace') throw failure
|
2026-07-12 03:51:55 +08:00
|
|
|
return originalHasOwn(object, property)
|
|
|
|
|
})
|
|
|
|
|
const seed = [{
|
|
|
|
|
type: 'user/message',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
2026-07-14 13:49:36 +08:00
|
|
|
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
|
|
|
|
surfaceOp: 'append',
|
|
|
|
|
}, {
|
|
|
|
|
type: 'user/message',
|
|
|
|
|
seq: 1,
|
|
|
|
|
time: 2,
|
2026-07-12 03:51:55 +08:00
|
|
|
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
|
|
|
|
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
2026-07-14 13:49:36 +08:00
|
|
|
sourceEventSeqs: [0],
|
2026-07-12 03:51:55 +08:00
|
|
|
}] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
|
2026-07-14 13:49:36 +08:00
|
|
|
.toThrow(`invalid seed event at index 1: ${expected}`)
|
2026-07-12 03:51:55 +08:00
|
|
|
} finally {
|
|
|
|
|
hasOwn.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-15 23:33:00 +08:00
|
|
|
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
|
|
|
|
|
const seed = [
|
|
|
|
|
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
2026-06-24 17:45:48 +08:00
|
|
|
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
2026-06-15 23:33:00 +08:00
|
|
|
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
|
|
|
|
] as SessionEvent[]
|
|
|
|
|
const session = new Session(SessionId('seed-snapshot'), seed)
|
|
|
|
|
// Mutate the ORIGINAL seed objects after construction: a shared reference
|
|
|
|
|
// would let this rewrite the forked log (or reintroduce non-serializable
|
|
|
|
|
// data past validation). The snapshot must shield session.events.
|
|
|
|
|
const um = seed[1]!
|
|
|
|
|
;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
|
|
|
|
|
;(um.data as Record<string, unknown>)['injected'] = 1n // would have failed validation
|
|
|
|
|
const logged = session.events[1]!
|
|
|
|
|
expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
|
|
|
|
|
expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
|
|
|
|
|
const session = new Session(SessionId('append-snapshot'))
|
|
|
|
|
const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
|
2026-06-23 13:05:59 +08:00
|
|
|
const event = session.append('user/message', data, { surfaceOp: 'append' })
|
2026-06-15 23:33:00 +08:00
|
|
|
// Mutate the caller's object after append returns. A shared reference would
|
|
|
|
|
// make session.events diverge from the value that passed validation.
|
|
|
|
|
data.content[0]!.text = 'HACKED'
|
|
|
|
|
;(data as Record<string, unknown>)['injected'] = 1n
|
|
|
|
|
const logged = session.events[0]!
|
|
|
|
|
expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
|
|
|
|
|
expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
|
|
|
|
|
// The returned event carries the same snapshot, not the caller's input.
|
|
|
|
|
expect((event.data.content[0] as { text: string }).text).toBe('original')
|
|
|
|
|
})
|
2026-07-12 03:51:55 +08:00
|
|
|
|
|
|
|
|
it('reads a nested append-data getter once and stores its first JSON value', () => {
|
|
|
|
|
const session = new Session(SessionId('append-nested-drift'))
|
|
|
|
|
let reads = 0
|
|
|
|
|
const data = Object.defineProperty({}, 'value', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => {
|
|
|
|
|
reads += 1
|
|
|
|
|
return reads === 1 ? 'accepted' : 1n
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const event = session.append('todo/write', data as never)
|
|
|
|
|
|
|
|
|
|
expect(reads).toBe(1)
|
|
|
|
|
expect(event.data).toEqual({ value: 'accepted' })
|
|
|
|
|
expect(session.events).toEqual([event])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects non-JSON surface metadata before appending the event', () => {
|
|
|
|
|
const session = new Session(SessionId('append-bad-metadata'))
|
|
|
|
|
|
|
|
|
|
expect(() => session.append(
|
|
|
|
|
'user/message',
|
|
|
|
|
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
|
|
|
|
{ surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
|
|
|
|
|
)).toThrow(/non-JSON-serializable surface metadata/)
|
|
|
|
|
expect(session.events).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects exotic surface metadata before cloning can erase its prototype', () => {
|
|
|
|
|
class ReplaceOp {
|
|
|
|
|
readonly op = 'replace' as const
|
|
|
|
|
readonly start = 0
|
|
|
|
|
readonly end = 0
|
|
|
|
|
}
|
|
|
|
|
const session = new Session(SessionId('append-exotic-metadata'))
|
|
|
|
|
|
|
|
|
|
expect(() => session.append(
|
|
|
|
|
'user/message',
|
|
|
|
|
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
|
|
|
|
{ surfaceOp: new ReplaceOp() },
|
|
|
|
|
)).toThrow(/non-JSON-serializable surface metadata/)
|
|
|
|
|
expect(session.events).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
|
|
|
|
|
const session = new Session(SessionId('append-unstable-metadata'))
|
2026-07-14 13:49:36 +08:00
|
|
|
const source = session.append(
|
|
|
|
|
'user/message',
|
|
|
|
|
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
|
|
|
|
{ surfaceOp: 'append' },
|
|
|
|
|
)
|
2026-07-12 03:51:55 +08:00
|
|
|
let reads = 0
|
|
|
|
|
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => {
|
|
|
|
|
reads += 1
|
|
|
|
|
return reads === 1 ? 0 : 1n
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const event = session.append(
|
|
|
|
|
'user/message',
|
|
|
|
|
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
2026-07-14 13:49:36 +08:00
|
|
|
{ surfaceOp, sourceEventSeqs: [0] } as never,
|
2026-07-12 03:51:55 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(reads).toBe(1)
|
|
|
|
|
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
2026-07-14 13:49:36 +08:00
|
|
|
expect(session.events).toEqual([source, event])
|
2026-07-12 03:51:55 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects invalid plain surface metadata shapes at append', () => {
|
|
|
|
|
const session = new Session(SessionId('append-invalid-surface-shape'))
|
|
|
|
|
const appendRaw = session.append.bind(session) as unknown as (
|
|
|
|
|
type: SessionEventType,
|
|
|
|
|
data: unknown,
|
|
|
|
|
opts?: unknown,
|
|
|
|
|
) => SessionEvent
|
|
|
|
|
const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }
|
|
|
|
|
|
|
|
|
|
expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' }))
|
|
|
|
|
.toThrow(/invalid surfaceOp/)
|
|
|
|
|
expect(() => appendRaw('user/message', data, {
|
|
|
|
|
surfaceOp: { op: 'replace', start: -1, end: 0 },
|
|
|
|
|
})).toThrow(/invalid replace surfaceOp/)
|
|
|
|
|
expect(() => appendRaw('user/message', data, {
|
|
|
|
|
surfaceOp: 'append',
|
|
|
|
|
sourceEventSeqs: [0, -1],
|
|
|
|
|
})).toThrow(/non-negative safe integers/)
|
|
|
|
|
expect(session.events).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects surface metadata on non-surface append and seed events', () => {
|
|
|
|
|
const session = new Session(SessionId('non-surface-metadata'))
|
|
|
|
|
const appendRaw = session.append.bind(session) as unknown as (
|
|
|
|
|
type: SessionEventType,
|
|
|
|
|
data: unknown,
|
|
|
|
|
opts?: unknown,
|
|
|
|
|
) => SessionEvent
|
|
|
|
|
|
|
|
|
|
expect(() => appendRaw(
|
|
|
|
|
'turn/start',
|
|
|
|
|
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
|
|
|
{ surfaceOp: 'append' },
|
2026-07-13 16:05:11 +08:00
|
|
|
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
2026-07-12 03:51:55 +08:00
|
|
|
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
|
|
|
|
type: 'turn/start',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
|
|
|
surfaceOp: 'append',
|
|
|
|
|
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
|
|
|
|
|
expect(session.events).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('deep-freezes seeded and appended event snapshots', () => {
|
|
|
|
|
const seeded = new Session(SessionId('seed-frozen'), [{
|
|
|
|
|
type: 'turn/start',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
|
|
|
}])
|
|
|
|
|
const seededEvent = seeded.events[0]!
|
|
|
|
|
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
|
|
|
|
|
expect(Object.isFrozen(seededEvent)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(seededEvent.data)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
|
|
|
|
|
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
|
|
|
|
|
|
|
|
|
const appended = new Session(SessionId('append-frozen'))
|
|
|
|
|
const appendedEvent = appended.append('todo/write', {
|
|
|
|
|
todos: [{ content: 'first', status: 'pending' }],
|
|
|
|
|
})
|
|
|
|
|
expect(Object.isFrozen(appendedEvent)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
|
|
|
|
|
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
|
|
|
|
const session = new Session(SessionId('events-snapshot'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
const before = session.events
|
|
|
|
|
const beforeEvent = before[0]!
|
|
|
|
|
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
|
|
|
|
|
|
|
|
|
|
expect(session.events).toBe(before)
|
|
|
|
|
expect(Object.isFrozen(before)).toBe(true)
|
|
|
|
|
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
|
|
|
|
|
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
|
|
|
|
|
|
|
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
|
const after = session.events
|
|
|
|
|
expect(before).toHaveLength(1)
|
|
|
|
|
expect(after).toHaveLength(2)
|
|
|
|
|
expect(after).not.toBe(before)
|
|
|
|
|
expect(session.events).toBe(after)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('detaches and freezes an explicitly supplied session header', () => {
|
|
|
|
|
const input = {
|
|
|
|
|
version: SESSION_FORMAT_VERSION,
|
|
|
|
|
id: SessionId('header-owned'),
|
|
|
|
|
createdAt: 123,
|
|
|
|
|
cwd: '/accepted',
|
|
|
|
|
parentSession: SessionId('parent'),
|
|
|
|
|
seedLength: 2,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const session = new Session(SessionId('header-owned'), undefined, input)
|
|
|
|
|
input.cwd = '/caller-mutated'
|
|
|
|
|
|
|
|
|
|
expect(session.header).toEqual({
|
|
|
|
|
version: SESSION_FORMAT_VERSION,
|
|
|
|
|
id: 'header-owned',
|
|
|
|
|
createdAt: 123,
|
|
|
|
|
cwd: '/accepted',
|
|
|
|
|
parentSession: 'parent',
|
|
|
|
|
seedLength: 2,
|
|
|
|
|
})
|
|
|
|
|
expect(session.header).not.toBe(input)
|
|
|
|
|
expect(Object.isFrozen(session.header)).toBe(true)
|
|
|
|
|
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
|
2026-07-12 05:13:17 +08:00
|
|
|
expect(session.id).toBe('header-owned')
|
2026-07-12 03:51:55 +08:00
|
|
|
expect(session.header.cwd).toBe('/accepted')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
|
|
|
|
|
class ExoticHeader implements SessionHeader {
|
|
|
|
|
readonly version = SESSION_FORMAT_VERSION
|
|
|
|
|
readonly id = SessionId('header-invalid')
|
|
|
|
|
readonly createdAt = 123
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
2026-07-12 22:36:04 +08:00
|
|
|
.toThrow(/not losslessly JSON-serializable/)
|
2026-07-12 03:51:55 +08:00
|
|
|
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
|
|
|
|
version: SESSION_FORMAT_VERSION,
|
|
|
|
|
id: SessionId('header-invalid'),
|
|
|
|
|
createdAt: 123,
|
|
|
|
|
parentSession: 1n,
|
|
|
|
|
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
|
|
|
|
|
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
|
|
|
|
version: SESSION_FORMAT_VERSION,
|
|
|
|
|
id: SessionId('other'),
|
|
|
|
|
createdAt: 123,
|
|
|
|
|
})).toThrow(/does not match session id/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects invalid scalar fields in an explicitly supplied header', () => {
|
|
|
|
|
const base = {
|
|
|
|
|
version: SESSION_FORMAT_VERSION,
|
|
|
|
|
id: SessionId('header-shape'),
|
|
|
|
|
createdAt: 123,
|
|
|
|
|
}
|
|
|
|
|
const cases: Array<{ header: unknown; error: RegExp }> = [
|
|
|
|
|
{ header: 1, error: /not a plain JSON record/ },
|
|
|
|
|
{ header: null, error: /not a plain JSON record/ },
|
|
|
|
|
{ header: { ...base, version: 1 }, error: /header version/ },
|
2026-07-24 10:35:09 +08:00
|
|
|
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
|
2026-07-12 03:51:55 +08:00
|
|
|
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
|
|
|
|
|
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
|
|
|
|
|
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
|
|
|
|
|
{ header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
|
|
|
|
{ header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
|
|
|
|
{ header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const { header, error } of cases) {
|
|
|
|
|
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects seed records with invalid fixed-envelope fields', () => {
|
|
|
|
|
const base = {
|
|
|
|
|
type: 'turn/start',
|
|
|
|
|
seq: 0,
|
|
|
|
|
time: 1,
|
|
|
|
|
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
|
|
|
}
|
|
|
|
|
const cases: unknown[] = [
|
|
|
|
|
{ ...base, extra: true },
|
|
|
|
|
{ ...base, type: 1 },
|
|
|
|
|
{ ...base, seq: '0' },
|
|
|
|
|
{ ...base, seq: 0.5 },
|
|
|
|
|
{ ...base, seq: -1 },
|
|
|
|
|
{ ...base, time: '1' },
|
|
|
|
|
{ ...base, time: 0.5 },
|
|
|
|
|
{ ...base, time: -1 },
|
|
|
|
|
{ type: base.type, seq: base.seq, time: base.time },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const [index, event] of cases.entries()) {
|
|
|
|
|
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
|
|
|
|
.toThrow(/invalid event envelope/)
|
|
|
|
|
}
|
|
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
describe('SessionStore', () => {
|
|
|
|
|
it('creates sessions, emits session/created and session/event', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
|
|
|
|
|
const created: Session[] = []
|
|
|
|
|
const events: [Session, SessionEvent][] = []
|
|
|
|
|
ctx.on('session/created', session => void created.push(session))
|
|
|
|
|
ctx.on('session/event', (session, event) => void events.push([session, event]))
|
|
|
|
|
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
expect(created).toEqual([session])
|
|
|
|
|
|
2026-07-12 18:57:42 +08:00
|
|
|
// The store-owned append publication hooks are module-private. A JavaScript caller
|
2026-07-12 05:13:17 +08:00
|
|
|
// may create an unrelated property with the old implementation's name,
|
|
|
|
|
// but cannot suppress the durable event feed.
|
|
|
|
|
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
|
2026-07-19 22:13:50 +08:00
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-06-23 13:05:59 +08:00
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
2026-07-19 22:13:50 +08:00
|
|
|
expect(events).toHaveLength(2)
|
|
|
|
|
expect(events[1]![0]).toBe(session)
|
|
|
|
|
expect(events[1]![1].type).toBe('user/message')
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
expect(ctx.sessions.get(session.id)).toBe(session)
|
|
|
|
|
expect(ctx.sessions.list()).toEqual([session])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects duplicate ids and supports seeding', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const a = ctx.sessions.create(SessionId('fixed'))
|
|
|
|
|
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-19 22:13:50 +08:00
|
|
|
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-06-23 13:05:59 +08:00
|
|
|
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-20 13:06:28 +08:00
|
|
|
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
|
2026-07-13 23:27:00 +08:00
|
|
|
// A stale prepared object must not replace the live same-id entry; its later
|
|
|
|
|
// detach would otherwise remove the wrong session.
|
2026-06-20 13:06:28 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const stale = ctx.sessions.prepare(SessionId('racy'))
|
|
|
|
|
const live = ctx.sessions.create(SessionId('racy'))
|
2026-06-20 13:06:28 +08:00
|
|
|
expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
|
|
|
|
|
// The live session is intact and still the store entry.
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
|
2026-06-20 13:06:28 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const created: Session[] = []
|
|
|
|
|
ctx.on('session/created', session => void created.push(session))
|
|
|
|
|
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const session = ctx.sessions.prepare(SessionId('lifecycle'))
|
2026-06-20 13:06:28 +08:00
|
|
|
// prepare alone does NOT enter the store.
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
2026-06-20 13:06:28 +08:00
|
|
|
const detach = ctx.sessions.enter(session)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
|
2026-06-20 13:06:28 +08:00
|
|
|
// enter does NOT announce.
|
|
|
|
|
expect(created).toEqual([])
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
expect(created).toEqual([session])
|
|
|
|
|
// The detach disposer removes the entry + stops notification.
|
|
|
|
|
detach()
|
2026-07-11 22:55:26 +08:00
|
|
|
detach() // idempotent: cannot disturb a later same-id lifecycle
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
2026-06-20 13:06:28 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('prevents simultaneous attachment of one session object to two stores', async () => {
|
2026-07-12 05:13:17 +08:00
|
|
|
const firstCtx = new Context()
|
|
|
|
|
const secondCtx = new Context()
|
|
|
|
|
await firstCtx.plugin(SessionStore)
|
|
|
|
|
await secondCtx.plugin(SessionStore)
|
|
|
|
|
const session = new Session(SessionId('owned-key'))
|
|
|
|
|
const detachFirst = firstCtx.sessions.enter(session)
|
|
|
|
|
|
|
|
|
|
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
|
|
|
|
|
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
|
|
|
|
|
|
|
|
|
|
detachFirst()
|
|
|
|
|
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined()
|
|
|
|
|
const detachSecond = secondCtx.sessions.enter(session)
|
|
|
|
|
expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
|
|
|
|
|
detachSecond()
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
let created = 0
|
|
|
|
|
let disposed = 0
|
|
|
|
|
let reentrantError = ''
|
|
|
|
|
ctx.on('session/created', (session) => {
|
|
|
|
|
created += 1
|
|
|
|
|
try {
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
reentrantError = String(error)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/disposed', () => { disposed += 1 })
|
|
|
|
|
|
|
|
|
|
const session = ctx.sessions.prepare(SessionId('once'))
|
|
|
|
|
const detach = ctx.sessions.enter(session)
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
expect(reentrantError).toMatch(/already announced/)
|
|
|
|
|
expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/)
|
|
|
|
|
detach()
|
|
|
|
|
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 08:57:05 +08:00
|
|
|
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const order: string[] = []
|
|
|
|
|
const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
|
|
|
|
|
const detach = ctx.sessions.enter(session)
|
|
|
|
|
|
|
|
|
|
ctx.on('session/created', (created) => {
|
|
|
|
|
order.push('created:first')
|
|
|
|
|
detach()
|
|
|
|
|
expect(ctx.sessions.get(created.id)).toBe(created)
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/created', (created) => {
|
|
|
|
|
order.push('created:second')
|
|
|
|
|
expect(ctx.sessions.get(created.id)).toBe(created)
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/disposed', (disposed) => {
|
|
|
|
|
order.push('disposed')
|
|
|
|
|
expect(ctx.sessions.get(disposed.id)).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
|
|
|
|
|
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
|
|
|
|
|
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
|
|
|
|
detach()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rolls back create when its owner unloads from session/created', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
let ownerCtx!: Context
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
|
|
|
|
|
const id = SessionId('create-unload-race')
|
|
|
|
|
ctx.on('session/created', (session) => {
|
|
|
|
|
if (session.id === id) void owner.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ownerCtx.sessions.create(id)
|
|
|
|
|
await owner.dispose()
|
|
|
|
|
expect(ctx.sessions.get(id)).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-21 11:08:10 +08:00
|
|
|
it('synthesizes a minimal current-version header for a bare-created session', async () => {
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const session = ctx.sessions.create(SessionId('plain'))
|
2026-06-21 11:08:10 +08:00
|
|
|
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
|
2026-07-24 10:35:09 +08:00
|
|
|
expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
expect(session.header.cwd).toBeUndefined()
|
|
|
|
|
expect(session.header.parentSession).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('attaches cwd and parentSession from meta to the header', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const session = ctx.sessions.create(SessionId('child'), {
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
|
|
|
|
|
})
|
|
|
|
|
expect(session.header).toMatchObject({
|
2026-06-21 11:08:10 +08:00
|
|
|
version: SESSION_FORMAT_VERSION,
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
id: 'child',
|
|
|
|
|
cwd: '/work/project',
|
|
|
|
|
parentSession: 'parent',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 17:20:36 +08:00
|
|
|
it('attaches delegationDepth from meta to the header', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const session = ctx.sessions.create(SessionId('delegated-child'), {
|
|
|
|
|
meta: { parentSession: SessionId('parent'), delegationDepth: 2 },
|
|
|
|
|
})
|
|
|
|
|
expect(session.header).toMatchObject({
|
|
|
|
|
id: 'delegated-child',
|
|
|
|
|
parentSession: 'parent',
|
|
|
|
|
delegationDepth: 2,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 03:51:55 +08:00
|
|
|
it('rejects non-JSON and invalid scalar session metadata', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const cases: Array<{ meta: unknown; error: RegExp }> = [
|
2026-07-12 22:36:04 +08:00
|
|
|
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
|
|
|
|
|
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
|
|
|
|
|
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
|
2026-07-24 10:35:09 +08:00
|
|
|
{ meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
|
2026-07-12 03:51:55 +08:00
|
|
|
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
2026-07-19 17:20:36 +08:00
|
|
|
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
|
|
|
|
|
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
|
2026-07-12 03:51:55 +08:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const [index, { meta, error }] of cases.entries()) {
|
|
|
|
|
expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), {
|
|
|
|
|
meta: meta as NonNullable<CreateSessionOptions['meta']>,
|
|
|
|
|
})).toThrow(error)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
it('rejects a non-absolute meta.cwd', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
.toThrow(/cwd must be an absolute path/)
|
|
|
|
|
// the rejected session was not registered
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-21 11:08:10 +08:00
|
|
|
it('a bare Session() constructed without the store still exposes a current-version header', () => {
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
const session = new Session(SessionId('bare'))
|
2026-06-21 11:08:10 +08:00
|
|
|
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
|
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
2026-06-15 17:54:55 +08:00
|
|
|
expect(typeof session.header.createdAt).toBe('number')
|
|
|
|
|
})
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
|
|
|
|
|
let session!: Session
|
|
|
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
session = inner.sessions.create(SessionId('scoped'))
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
}, { inject: ['sessions'] }))
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
let observed = 0
|
|
|
|
|
ctx.on('session/event', () => void observed++)
|
|
|
|
|
|
|
|
|
|
await fiber.dispose()
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
|
2026-06-23 13:05:59 +08:00
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(observed).toBe(0)
|
|
|
|
|
})
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
|
2026-07-12 05:13:17 +08:00
|
|
|
it('pairs a partial session/created announcement with disposal during rollback', async () => {
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
|
|
|
|
|
let threw = false
|
2026-07-12 05:13:17 +08:00
|
|
|
const disposed: Session[] = []
|
|
|
|
|
ctx.on('session/disposed', (session) => { disposed.push(session) })
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
ctx.on('session/created', () => {
|
|
|
|
|
if (!threw) { threw = true; throw new Error('boom created listener') }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// The throwing emit must roll the store entry back, not leak it.
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
|
|
|
|
|
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
|
2026-07-12 05:13:17 +08:00
|
|
|
expect(disposed.map(session => session.id)).toEqual(['fixed'])
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
|
|
|
|
|
// A subsequent create of the SAME id succeeds (the already-exists check is
|
2026-07-12 18:57:42 +08:00
|
|
|
// not wedged) and its store-owned publication hooks are correctly wired.
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
const events: SessionEvent[] = []
|
|
|
|
|
ctx.on('session/event', (_session, event) => void events.push(event))
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const session = ctx.sessions.create(SessionId('fixed'))
|
|
|
|
|
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
|
2026-07-19 22:13:50 +08:00
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-06-23 13:05:59 +08:00
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
2026-07-19 22:13:50 +08:00
|
|
|
expect(events.at(-1)?.type).toBe('user/message')
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
})
|
2026-07-12 05:13:17 +08:00
|
|
|
|
2026-07-12 18:57:42 +08:00
|
|
|
it('contains session/event observer failures after the append commit point', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
const session = ctx.sessions.create(SessionId('contained-event'))
|
|
|
|
|
const heard: SessionEvent[] = []
|
|
|
|
|
let committedBeforeNotify = false
|
|
|
|
|
ctx.on('session/event', (observedSession, event) => {
|
|
|
|
|
committedBeforeNotify = observedSession.events.at(-1) === event
|
|
|
|
|
throw new Error('sync event observer')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
|
|
|
|
|
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
|
|
|
|
|
|
|
|
|
let appended!: SessionEvent
|
|
|
|
|
expect(() => {
|
|
|
|
|
appended = session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})
|
|
|
|
|
}).not.toThrow()
|
|
|
|
|
expect(committedBeforeNotify).toBe(true)
|
|
|
|
|
expect(session.events).toEqual([appended])
|
|
|
|
|
expect(heard).toEqual([appended])
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
|
|
|
|
|
expect(warnings).toEqual([
|
|
|
|
|
'session "contained-event": session/event listener threw: Error: sync event observer',
|
|
|
|
|
'session "contained-event": session/event listener rejected: Error: async event observer',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const session = ctx.sessions.create(SessionId('dispatch-veto'))
|
|
|
|
|
const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
|
|
|
|
|
const observed: SessionEvent[] = []
|
|
|
|
|
let reject = true
|
|
|
|
|
ctx.on('internal/dispatch', (_mode, name, args) => {
|
|
|
|
|
if (name !== 'session/event') return
|
|
|
|
|
const [observedSession, event] = args as [Session, SessionEvent]
|
|
|
|
|
validations.push({
|
|
|
|
|
event,
|
|
|
|
|
logLength: observedSession.events.length,
|
|
|
|
|
frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
|
|
|
|
|
})
|
|
|
|
|
if (reject) {
|
|
|
|
|
reject = false
|
|
|
|
|
throw new Error('reject first candidate')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
|
|
|
|
|
|
|
|
|
expect(() => session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})).toThrow('reject first candidate')
|
|
|
|
|
expect(session.events).toEqual([])
|
|
|
|
|
expect(observed).toEqual([])
|
|
|
|
|
|
|
|
|
|
const appended = session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})
|
|
|
|
|
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
|
|
|
|
|
{ logLength: 0, frozen: true },
|
|
|
|
|
{ logLength: 0, frozen: true },
|
|
|
|
|
])
|
|
|
|
|
expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
|
|
|
|
|
expect(validations[1]!.event).toBe(appended)
|
|
|
|
|
expect(session.events).toEqual([appended])
|
|
|
|
|
expect(observed).toEqual([appended])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 11:36:07 +08:00
|
|
|
it('does not publish a surface transition rejected by internal dispatch', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
|
2026-07-19 22:13:50 +08:00
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
2026-07-19 11:36:07 +08:00
|
|
|
session.append('user/message', {
|
|
|
|
|
content: [{ type: 'text', text: 'source' }],
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
const surface = session.surface
|
|
|
|
|
let reject = true
|
|
|
|
|
ctx.on('internal/dispatch', (_mode, name) => {
|
|
|
|
|
if (name === 'session/event' && reject) {
|
|
|
|
|
reject = false
|
|
|
|
|
throw new Error('reject surface candidate')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(() => session.append('assistant/message', {
|
|
|
|
|
provenance: { provider: 'mock', model: 'mock' },
|
|
|
|
|
turn: 1,
|
|
|
|
|
step: 1,
|
|
|
|
|
content: [{ type: 'text', text: 'replacement' }],
|
|
|
|
|
}, {
|
2026-07-19 22:13:50 +08:00
|
|
|
surfaceOp: { op: 'replace', start: 2, end: 2 },
|
|
|
|
|
sourceEventSeqs: [2],
|
2026-07-19 11:36:07 +08:00
|
|
|
})).toThrow('reject surface candidate')
|
|
|
|
|
|
2026-07-19 22:13:50 +08:00
|
|
|
expect(session.events).toHaveLength(3)
|
|
|
|
|
expect(surface.nodes).toEqual([2])
|
2026-07-19 11:36:07 +08:00
|
|
|
expect(surface.replaceGeneration).toBe(0)
|
|
|
|
|
|
|
|
|
|
session.append('user/message', {
|
|
|
|
|
content: [{ type: 'text', text: 'next' }],
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
}, { surfaceOp: 'append' })
|
2026-07-19 22:13:50 +08:00
|
|
|
expect(surface.nodes).toEqual([2, 3])
|
2026-07-19 11:36:07 +08:00
|
|
|
expect(surface.replaceGeneration).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 18:57:42 +08:00
|
|
|
it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const session = ctx.sessions.create(SessionId('dispatch-check'))
|
|
|
|
|
const observed: SessionEvent[] = []
|
|
|
|
|
ctx.on('internal/dispatch', (_mode, name) => {
|
|
|
|
|
if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
|
|
|
|
|
|
|
|
|
expect(() => session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})).toThrow('dispatch instrumentation rejected the carrier')
|
|
|
|
|
expect(session.events).toEqual([])
|
|
|
|
|
expect(observed).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('contains a reentrant observer append without reordering later observers', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
const session = ctx.sessions.create(SessionId('reentrant-observer'))
|
|
|
|
|
const heard: SessionEvent[] = []
|
|
|
|
|
ctx.on('session/event', (observedSession) => {
|
|
|
|
|
observedSession.append('todo/write', { todos: [] })
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
|
|
|
|
|
|
|
|
|
const appended = session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})
|
|
|
|
|
expect(session.events).toEqual([appended])
|
|
|
|
|
expect(heard).toEqual([appended])
|
|
|
|
|
expect(warnings).toEqual([
|
2026-07-12 22:36:04 +08:00
|
|
|
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
|
2026-07-12 18:57:42 +08:00
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const order: string[] = []
|
|
|
|
|
const session = ctx.sessions.prepare(SessionId('detach-during-append'))
|
|
|
|
|
const detach = ctx.sessions.enter(session)
|
|
|
|
|
ctx.on('internal/dispatch', (_mode, name, args) => {
|
|
|
|
|
if (name !== 'session/event') return
|
|
|
|
|
const session = args[0] as Session
|
|
|
|
|
order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
|
|
|
|
detach()
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/event', (session) => {
|
|
|
|
|
order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/disposed', (session) => {
|
|
|
|
|
order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
|
|
|
|
})
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
|
|
|
|
|
const appended = session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(session.events).toEqual([appended])
|
|
|
|
|
expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
|
|
|
|
|
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 05:13:17 +08:00
|
|
|
it('observes async session/created rejection without rolling back or starving peers', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
const heard: string[] = []
|
|
|
|
|
ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never)
|
|
|
|
|
ctx.on('session/created', (session) => { heard.push(session.id) })
|
|
|
|
|
|
|
|
|
|
const session = ctx.sessions.create(SessionId('async-created'))
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
|
|
|
|
|
expect(ctx.sessions.get(session.id)).toBe(session)
|
|
|
|
|
expect(heard).toEqual(['async-created'])
|
|
|
|
|
expect(warnings).toEqual([
|
|
|
|
|
'session "async-created": session/created listener rejected: Error: late creation failure',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('contains synchronous and async session/disposed listener failures per observer', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
const heard: string[] = []
|
2026-07-12 22:36:04 +08:00
|
|
|
ctx.on('session/disposed', () => { throw new Error('sync disposed') })
|
2026-07-12 05:13:17 +08:00
|
|
|
ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
|
|
|
|
|
ctx.on('session/disposed', (session) => { heard.push(session.id) })
|
|
|
|
|
|
|
|
|
|
const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
|
|
|
|
|
const detachUnannounced = ctx.sessions.enter(unannounced)
|
|
|
|
|
detachUnannounced()
|
|
|
|
|
expect(heard).toEqual([])
|
|
|
|
|
|
|
|
|
|
const announced = ctx.sessions.prepare(SessionId('contained-disposal'))
|
|
|
|
|
const detach = ctx.sessions.enter(announced)
|
|
|
|
|
ctx.sessions.announce(announced)
|
|
|
|
|
expect(() => { detach() }).not.toThrow()
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
|
|
|
|
|
expect(heard).toEqual(['contained-disposal'])
|
|
|
|
|
expect(warnings).toEqual([
|
2026-07-12 22:36:04 +08:00
|
|
|
'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
|
2026-07-12 05:13:17 +08:00
|
|
|
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-07-12 18:57:42 +08:00
|
|
|
|
|
|
|
|
it('contains internal dispatch failure after session detachment', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
const heard: Session[] = []
|
|
|
|
|
ctx.on('internal/dispatch', (_mode, name) => {
|
|
|
|
|
if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/disposed', (session) => { heard.push(session) })
|
|
|
|
|
const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
|
|
|
|
|
const detach = ctx.sessions.enter(session)
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
|
|
|
|
|
expect(() => { detach() }).not.toThrow()
|
|
|
|
|
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
|
|
|
|
expect(heard).toEqual([])
|
|
|
|
|
expect(warnings).toEqual([
|
|
|
|
|
'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not let internal dispatch replace the disposed callback tuple', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const replacement = new Session(SessionId('replacement-disposed'))
|
|
|
|
|
const heard: Session[] = []
|
|
|
|
|
ctx.on('internal/dispatch', (_mode, name, args) => {
|
|
|
|
|
if (name === 'session/disposed') args[0] = replacement
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/disposed', (session) => { heard.push(session) })
|
|
|
|
|
const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
|
|
|
|
|
const detach = ctx.sessions.enter(session)
|
|
|
|
|
ctx.sessions.announce(session)
|
|
|
|
|
|
|
|
|
|
detach()
|
|
|
|
|
|
|
|
|
|
expect(heard).toEqual([session])
|
|
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
2026-06-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
describe('todo/write event', () => {
|
|
|
|
|
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
|
|
|
|
const session = new Session(SessionId('t1'))
|
|
|
|
|
const todos: TodoItem[] = [
|
|
|
|
|
{ content: 'plan the work', status: 'in_progress' },
|
|
|
|
|
{ content: 'write the code', status: 'pending' },
|
|
|
|
|
]
|
|
|
|
|
session.append('todo/write', { todos })
|
|
|
|
|
|
|
|
|
|
const event = session.events.findLast(e => e.type === 'todo/write')!
|
|
|
|
|
expect(event.type).toBe('todo/write')
|
|
|
|
|
expect(event.data.todos).toEqual(todos)
|
|
|
|
|
|
|
|
|
|
// The append snapshots its input: mutating the caller's array afterward must
|
|
|
|
|
// not change what the log holds (the durable-source-of-truth contract).
|
|
|
|
|
todos.push({ content: 'sneak in', status: 'pending' })
|
|
|
|
|
todos[0]!.status = 'completed'
|
|
|
|
|
expect(event.data.todos).toEqual([
|
|
|
|
|
{ content: 'plan the work', status: 'in_progress' },
|
|
|
|
|
{ content: 'write the code', status: 'pending' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
|
|
|
|
const session = new Session(SessionId('t2'))
|
|
|
|
|
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
|
|
|
|
session.append('todo/write', { todos: [
|
|
|
|
|
{ content: 'first', status: 'completed' },
|
|
|
|
|
{ content: 'second', status: 'in_progress' },
|
|
|
|
|
] })
|
|
|
|
|
|
|
|
|
|
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
|
|
|
|
|
expect(current).toEqual([
|
|
|
|
|
{ content: 'first', status: 'completed' },
|
|
|
|
|
{ content: 'second', status: 'in_progress' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
|
|
|
|
|
const session = new Session(SessionId('t3'))
|
|
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
const before = session.deriveMessages().length
|
|
|
|
|
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
|
|
|
|
|
// The todo event must not add a message to the derived history…
|
|
|
|
|
expect(session.deriveMessages()).toHaveLength(before)
|
2026-07-13 23:56:10 +08:00
|
|
|
// …and must not appear on the ordered surface.
|
|
|
|
|
expect(session.surface.nodes).not.toContain(session.seq - 1)
|
2026-06-29 01:39:25 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
|
|
|
|
const original = new Session(SessionId('t4'))
|
2026-07-06 14:17:31 +08:00
|
|
|
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-06-29 01:39:25 +08:00
|
|
|
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
2026-07-06 14:17:31 +08:00
|
|
|
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
2026-06-29 01:39:25 +08:00
|
|
|
// Seeding a non-surface event with no surfaceOp must not throw.
|
|
|
|
|
const replayed = new Session(SessionId('t4-replay'), [...original.events])
|
|
|
|
|
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
|
|
|
|
.toEqual([{ content: 'only', status: 'completed' }])
|
|
|
|
|
expect(replayed.seq).toBe(original.seq)
|
|
|
|
|
})
|
|
|
|
|
})
|