Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # apps/web/tests/markdown-images.e2e.ts # docs/cordis-catalog/services.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/context/time-context/tests/invariant.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/core/agent-loop/src/invariant.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent/tests/agent.spec.ts # packages/core/session/README.i18n.yaml # packages/core/session/src/index.ts # packages/core/session/tests/derived-cache.spec.ts # packages/core/session/tests/request-header.spec.ts # packages/core/session/tests/session.spec.ts # packages/core/session/tests/surface.spec.ts # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/plan/plan-mode/tests/plan-mode.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -179,7 +179,7 @@ function fixtureLog(session: Session): string {
|
||||
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
|
||||
const turns = options.turns ?? DEFAULT_TURNS
|
||||
const markers = markerHelpers(options.markerPrefix)
|
||||
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
|
||||
const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
|
||||
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
|
||||
@@ -319,7 +319,7 @@ function fixtureLog(session: Session): string {
|
||||
}
|
||||
|
||||
function smallSidebarFixture(): string {
|
||||
const session = new Session(SessionId('perf-small-template'))
|
||||
const session = Session.create(SessionId('perf-small-template'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
@@ -341,7 +341,7 @@ function smallSidebarFixture(): string {
|
||||
}
|
||||
|
||||
function longHistoryFixture(): string {
|
||||
const session = new Session(SessionId(LONG_SESSION_ID))
|
||||
const session = Session.create(SessionId(LONG_SESSION_ID))
|
||||
for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
|
||||
@@ -82,7 +82,7 @@ async function stopServer(server: Server): Promise<void> {
|
||||
|
||||
/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
|
||||
function markdownImageFixture(remoteUrl: string): string {
|
||||
const session = new Session(SessionId('markdown-image-source'))
|
||||
const session = Session.create(SessionId('markdown-image-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:212`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-spine-demo`
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
|
||||
|
||||
Types: [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:157`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:158`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:252`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:253`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
@@ -1690,7 +1690,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:738`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:767`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.sessionTitle` — `SessionTitleService`
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
|
||||
session.md: 3e79a9278578e9fe3860bdbe72ff61319bbfa2c5
|
||||
session.zh.md: ff0845b7228549eb12009f764c8d5fc60f9b578c
|
||||
session.md: f4655c84d214284ef5269824c09d9f04a88b2c0a
|
||||
session.zh.md: f6e6573b6b2bc05d18c5d1de146e6bfd255d010a
|
||||
@@ -341,13 +341,14 @@ interface SurfaceFoldResult {
|
||||
|
||||
## `Session` public API
|
||||
|
||||
The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore).
|
||||
The body-stripped declaration keeps the plain class's detached factory, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore).
|
||||
|
||||
```ts public-api
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Plain class (not a Service) — create live instances via
|
||||
* `ctx.sessions.create()` and detached instances via {@link create}.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
* @typert object
|
||||
*/
|
||||
@@ -388,7 +389,15 @@ declare class Session {
|
||||
* holds an ordinary published write.
|
||||
*/
|
||||
readonly firstLiveSeq: number;
|
||||
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
|
||||
/**
|
||||
* Create a detached session by validating and snapshotting borrowed seed
|
||||
* events and storage metadata.
|
||||
* @param id - session identity.
|
||||
* @param seed - optional borrowed replay or fork events.
|
||||
* @param header - optional borrowed storage metadata.
|
||||
* @returns a detached session.
|
||||
*/
|
||||
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;
|
||||
/**
|
||||
* An immutable snapshot of the append-only event log. The snapshot is reused
|
||||
* until the next append; a previously returned array does not grow later.
|
||||
|
||||
@@ -343,13 +343,14 @@ interface SurfaceFoldResult {
|
||||
|
||||
## `Session` 公共 API
|
||||
|
||||
去除方法体的声明与源码中的普通类保持同步,覆盖其公共构造函数、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。
|
||||
去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。
|
||||
|
||||
```ts public-api
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Plain class (not a Service) — create live instances via
|
||||
* `ctx.sessions.create()` and detached instances via {@link create}.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
* @typert object
|
||||
*/
|
||||
@@ -390,7 +391,15 @@ declare class Session {
|
||||
* holds an ordinary published write.
|
||||
*/
|
||||
readonly firstLiveSeq: number;
|
||||
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
|
||||
/**
|
||||
* Create a detached session by validating and snapshotting borrowed seed
|
||||
* events and storage metadata.
|
||||
* @param id - session identity.
|
||||
* @param seed - optional borrowed replay or fork events.
|
||||
* @param header - optional borrowed storage metadata.
|
||||
* @returns a detached session.
|
||||
*/
|
||||
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;
|
||||
/**
|
||||
* An immutable snapshot of the append-only event log. The snapshot is reused
|
||||
* until the next append; a previously returned array does not grow later.
|
||||
|
||||
@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:158`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:187`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,7 +67,7 @@ async function harness(): Promise<Harness> {
|
||||
await ctx.plugin(CommandService)
|
||||
const compact = new StubCompactService(ctx)
|
||||
const plugin = await ctx.plugin(commandCompact)
|
||||
const session = new Session(SessionId('command-compact'))
|
||||
const session = Session.create(SessionId('command-compact'))
|
||||
const agent = {
|
||||
session,
|
||||
status: 'idle',
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('command-compact real Loader composition', () => {
|
||||
})
|
||||
await context.loader.await()
|
||||
|
||||
const session = new Session(SessionId('loader-command-compact'))
|
||||
const session = Session.create(SessionId('loader-command-compact'))
|
||||
const agent = {
|
||||
session,
|
||||
status: 'idle',
|
||||
|
||||
@@ -103,7 +103,7 @@ function promptInput(text: string): SummarizationInput {
|
||||
|
||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
const session = new Session(SessionId(`conversation-${turns}`))
|
||||
const session = Session.create(SessionId(`conversation-${turns}`))
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -139,7 +139,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
}
|
||||
|
||||
function toolConversation(): Session {
|
||||
const session = new Session(SessionId('tools'))
|
||||
const session = Session.create(SessionId('tools'))
|
||||
for (let turn = 1; turn <= 3; turn += 1) {
|
||||
const callId = CallId(`call-${turn}`)
|
||||
session.append('turn/start', { turn })
|
||||
@@ -188,7 +188,7 @@ function toolConversation(): Session {
|
||||
|
||||
/** One closed routed tool step followed by an open turn for rewrite events. */
|
||||
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
|
||||
const session = new Session(SessionId(`oversized-tool-${chars}`))
|
||||
const session = Session.create(SessionId(`oversized-tool-${chars}`))
|
||||
const callId = CallId('oversized')
|
||||
session.append('turn/start', { turn: 1 })
|
||||
if (withCompactablePrompt) {
|
||||
@@ -484,7 +484,7 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('headerless'))
|
||||
const session = Session.create(SessionId('headerless'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
|
||||
.resolves.toBeNull()
|
||||
@@ -566,7 +566,7 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('single-tool-pair'))
|
||||
const session = Session.create(SessionId('single-tool-pair'))
|
||||
const callId = CallId('single-call')
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -657,7 +657,7 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const empty = new Session(SessionId('empty'))
|
||||
const empty = Session.create(SessionId('empty'))
|
||||
empty.append('turn/start', { turn: 1 })
|
||||
empty.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
@@ -732,7 +732,7 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('declines when rounding a cut would consume the only tool pair', () => {
|
||||
const ctx = createContext()
|
||||
const session = new Session(SessionId('one-tool-pair'))
|
||||
const session = Session.create(SessionId('one-tool-pair'))
|
||||
const callId = CallId('only')
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -873,7 +873,7 @@ describe('compaction region transaction', () => {
|
||||
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
|
||||
expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
|
||||
|
||||
const replay = new Session(SessionId('replay'), [...session.events])
|
||||
const replay = Session.create(SessionId('replay'), [...session.events])
|
||||
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
||||
})
|
||||
|
||||
@@ -955,7 +955,7 @@ describe('compaction region transaction', () => {
|
||||
|
||||
it('rejects a session with no turn boundary at all', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('turnless'))
|
||||
const session = Session.create(SessionId('turnless'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'orphan' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -1075,7 +1075,7 @@ describe('compaction region transaction', () => {
|
||||
|
||||
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('model-less-region'))
|
||||
const session = Session.create(SessionId('model-less-region'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
||||
@@ -1312,13 +1312,13 @@ describe('default one-shot summarizer', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx)
|
||||
const compact = new ExposedCompactService(ctx, { auto: false })
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less')))))
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less')))))
|
||||
.rejects.toThrow(/no provider\/model available for summarization/)
|
||||
})
|
||||
|
||||
it('uses a complete AgentOptions target when no durable route exists', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
||||
const session = new Session(SessionId('headerless-summary'))
|
||||
const session = Session.create(SessionId('headerless-summary'))
|
||||
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
|
||||
provider: MODEL,
|
||||
@@ -1334,7 +1334,7 @@ describe('default one-shot summarizer', () => {
|
||||
])('rejects incomplete AgentOptions target %#', async (options) => {
|
||||
const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
|
||||
const owner = {
|
||||
session: new Session(SessionId(`incomplete-${String(options.model)}`)),
|
||||
session: Session.create(SessionId(`incomplete-${String(options.model)}`)),
|
||||
options,
|
||||
} as Agent
|
||||
await expect(compact.runSummarize(promptInput('history'), owner))
|
||||
@@ -1703,7 +1703,7 @@ describe('automatic listener and loader composition', () => {
|
||||
it('delegates canonical overflow when no durable routed target exists', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx)
|
||||
const session = new Session(SessionId('headerless-overflow'))
|
||||
const session = Session.create(SessionId('headerless-overflow'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
|
||||
@@ -185,7 +185,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function overflowHistorySeed(): SessionEvent[] {
|
||||
const session = new Session(SessionId('overflow-history-seed'))
|
||||
const session = Session.create(SessionId('overflow-history-seed'))
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
session.append('turn/start', {
|
||||
|
||||
@@ -169,7 +169,7 @@ function deferred(): { promise: Promise<undefined>; resolve: () => void } {
|
||||
|
||||
/** A closed-tail session with compactable exchanges and no live agent. */
|
||||
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
|
||||
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
|
||||
const session = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`))
|
||||
for (let index = 1; index <= turns; index += 1) {
|
||||
const turn = index === turns ? lastTurnNumber : index
|
||||
session.append('turn/start', { turn })
|
||||
@@ -380,7 +380,7 @@ describe('compactNow through the real loop', () => {
|
||||
describe('compactNow transaction and failure classification', () => {
|
||||
it('returns null without writing a bracket for history that cannot be compacted', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = new Session(SessionId('empty'))
|
||||
const session = Session.create(SessionId('empty'))
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
|
||||
@@ -422,7 +422,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const { compact } = detachedService()
|
||||
const original = closedConversation(2)
|
||||
original.append('compact/start', { turn: null })
|
||||
const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
|
||||
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
|
||||
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
|
||||
const orphan = reloaded.events.find(event => event.type === 'compact/start')
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
@@ -438,7 +438,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
original.append('compact/start', { turn: null })
|
||||
original.append('turn/start', { turn: 3 })
|
||||
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
|
||||
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
|
||||
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
|
||||
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
|
||||
@@ -650,7 +650,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
|
||||
it('compacts a session with no durable turn boundary without creating one', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = new Session(SessionId('turnless'))
|
||||
const session = Session.create(SessionId('turnless'))
|
||||
for (const text of [PROMPT, 'recent tail']) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
@@ -683,7 +683,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
|
||||
const cases = [
|
||||
{ name: 'busy', session: closedConversation(2), release: undefined },
|
||||
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
|
||||
{ name: 'empty', session: Session.create(SessionId('pre-aborted-empty')), release: () => undefined },
|
||||
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
|
||||
] as const
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('ToolResultPruneService content transform', () => {
|
||||
|
||||
describe('ToolResultPruneService session transaction', () => {
|
||||
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
|
||||
const session = new Session(SessionId('preserve'))
|
||||
const session = Session.create(SessionId('preserve'))
|
||||
const originalSeq = appendToolStep(session, 1, 'one', [{
|
||||
type: 'text',
|
||||
text: 'x'.repeat(100),
|
||||
@@ -206,7 +206,7 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
})
|
||||
|
||||
it('prunes multiple results, skips short ones, and converges in one pass', () => {
|
||||
const session = new Session(SessionId('multiple'))
|
||||
const session = Session.create(SessionId('multiple'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
|
||||
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
|
||||
@@ -224,13 +224,13 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
})
|
||||
|
||||
it('replays to the identical pruned model messages', () => {
|
||||
const session = new Session(SessionId('replay'))
|
||||
const session = Session.create(SessionId('replay'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
})
|
||||
service().pruneSession(session)
|
||||
const replay = new Session(session.id, [...session.events])
|
||||
const replay = Session.create(session.id, [...session.events])
|
||||
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
||||
expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
|
||||
})
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('CompactService seam', () => {
|
||||
it('exposes the abstract contract methods', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
|
||||
const signal = new AbortController().signal
|
||||
expect(await svc.compactNow({
|
||||
@@ -118,7 +118,7 @@ describe('CompactService seam', () => {
|
||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
const original = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -149,7 +149,7 @@ describe('CompactService seam', () => {
|
||||
it('threads the cancellation signal through to the backend', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
const controller = new AbortController()
|
||||
const original = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('compaction invariants', () => {
|
||||
it('clears an inherited open compaction trace at end-seed during replay', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-compaction-source'))
|
||||
const source = Session.create(SessionId('stale-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
|
||||
seed: source.events,
|
||||
@@ -76,7 +76,7 @@ describe('compaction invariants', () => {
|
||||
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-numbered-compaction-source'))
|
||||
const source = Session.create(SessionId('stale-numbered-compaction-source'))
|
||||
startTurn(source)
|
||||
source.append('compact/start', { turn: 1 })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
|
||||
@@ -97,7 +97,7 @@ describe('compaction invariants', () => {
|
||||
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-repaired-compaction-source'))
|
||||
const source = Session.create(SessionId('stale-repaired-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
@@ -123,7 +123,7 @@ describe('compaction invariants', () => {
|
||||
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('closed-nested-compaction-source'))
|
||||
const source = Session.create(SessionId('closed-nested-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
@@ -152,7 +152,7 @@ describe('compaction invariants', () => {
|
||||
|
||||
it('adopts a bare session and ignores unrelated committed events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('bare-compaction-session'))
|
||||
const session = Session.create(SessionId('bare-compaction-session'))
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
|
||||
@@ -25,7 +25,7 @@ function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
}
|
||||
|
||||
function closedToolStep(): Session {
|
||||
const session = new Session(SessionId('closed-tool-step'))
|
||||
const session = Session.create(SessionId('closed-tool-step'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -64,7 +64,7 @@ describe('tool-pairing boundaries', () => {
|
||||
expect(before(closed, 'tool/result')).toBe(false)
|
||||
expect(after(closed, 'tool/result')).toBe(true)
|
||||
|
||||
const open = new Session(SessionId('open-tool-step'))
|
||||
const open = Session.create(SessionId('open-tool-step'))
|
||||
open.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -81,7 +81,7 @@ describe('tool-pairing boundaries', () => {
|
||||
})
|
||||
|
||||
it('requires every result from a multiple-call assistant message', () => {
|
||||
const session = new Session(SessionId('multiple-calls'))
|
||||
const session = Session.create(SessionId('multiple-calls'))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -119,7 +119,7 @@ describe('tool-pairing boundaries', () => {
|
||||
})
|
||||
|
||||
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
|
||||
const midStep = new Session(SessionId('neutral-mid-step'))
|
||||
const midStep = Session.create(SessionId('neutral-mid-step'))
|
||||
midStep.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -147,7 +147,7 @@ describe('tool-pairing boundaries', () => {
|
||||
expect(before(midStep, 'user/message')).toBe(false)
|
||||
expect(after(midStep, 'user/message')).toBe(false)
|
||||
|
||||
const free = new Session(SessionId('neutral-free'))
|
||||
const free = Session.create(SessionId('neutral-free'))
|
||||
free.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'idle injection' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -187,7 +187,7 @@ describe('tool-pairing surface identity', () => {
|
||||
})
|
||||
|
||||
it('rejects missing seqs before and after, including an empty surface', () => {
|
||||
const session = new Session(SessionId('missing-membership'))
|
||||
const session = Session.create(SessionId('missing-membership'))
|
||||
const missing = 999
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
@@ -366,7 +366,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
|
||||
describe('tool-pairing corrupt surfaces', () => {
|
||||
it('throws for an orphan result during a rebuild', () => {
|
||||
const session = new Session(SessionId('orphan-rebuild'))
|
||||
const session = Session.create(SessionId('orphan-rebuild'))
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
@@ -379,7 +379,7 @@ describe('tool-pairing corrupt surfaces', () => {
|
||||
})
|
||||
|
||||
it('retries an orphan result in an appended tail without committing partial cache state', () => {
|
||||
const session = new Session(SessionId('orphan-tail'))
|
||||
const session = Session.create(SessionId('orphan-tail'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
|
||||
}), SURFACE)
|
||||
|
||||
@@ -630,7 +630,7 @@ describe('session reference discovery and preparation', () => {
|
||||
expect(JSON.stringify(before)).toContain('durable referenced fact')
|
||||
expect(JSON.stringify(before)).toContain('use @source')
|
||||
expect(JSON.stringify(before)).not.toContain('later source mutation')
|
||||
expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
|
||||
expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects direct invalid configuration before service publication', async () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ function reading(
|
||||
}
|
||||
|
||||
function preparing(turn: number, step: number): Session {
|
||||
const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
|
||||
const session = Session.create(SessionId(`time-invariant-${turn}-${step}`))
|
||||
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
|
||||
session.append('turn/start', { turn: priorTurn })
|
||||
session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
|
||||
@@ -137,11 +137,11 @@ describe('time-context invariants', () => {
|
||||
const ended = preparing(1, 1)
|
||||
ended.append('step/end', { turn: 1, step: 1 })
|
||||
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/)
|
||||
const notEntered = new Session(SessionId('time-invariant-turn-only'))
|
||||
const notEntered = Session.create(SessionId('time-invariant-turn-only'))
|
||||
notEntered.append('turn/start', { turn: 1 })
|
||||
expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/)
|
||||
expect(() => {
|
||||
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
|
||||
ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
|
||||
}).toThrow(/at a prompt boundary/)
|
||||
})
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ function requestText(request: GenerateOptions): string {
|
||||
describe('durable step context', () => {
|
||||
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
|
||||
const session = new Session(SessionId('first'))
|
||||
const session = Session.create(SessionId('first'))
|
||||
openMessageTurn(session, 1)
|
||||
vi.setSystemTime(BASE + 90_061_000)
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('unavailable'))
|
||||
const session = Session.create(SessionId('unavailable'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -182,7 +182,7 @@ describe('durable step context', () => {
|
||||
['zero interval', { refreshIntervalMs: 0 }],
|
||||
] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
|
||||
const { ctx } = await mount(config)
|
||||
const session = new Session(SessionId('later-step'))
|
||||
const session = Session.create(SessionId('later-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 3)
|
||||
await fire(ctx, agent, 3, 1)
|
||||
@@ -198,7 +198,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('later-step-boundary'))
|
||||
const session = Session.create(SessionId('later-step-boundary'))
|
||||
openMessageTurn(session, 4)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 4, 2)
|
||||
@@ -210,7 +210,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('later-step-exhausted'))
|
||||
const session = Session.create(SessionId('later-step-exhausted'))
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 2)
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('backward'))
|
||||
const session = Session.create(SessionId('backward'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
@@ -235,7 +235,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const original = new Session(SessionId('seed-source'))
|
||||
const original = Session.create(SessionId('seed-source'))
|
||||
openMessageTurn(original, 1)
|
||||
await fire(ctx, sessionAgent(original), 1, 1)
|
||||
const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
|
||||
@@ -251,7 +251,7 @@ describe('durable step context', () => {
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
|
||||
|
||||
const resumed = new Session(SessionId('resumed'), [...original.events])
|
||||
const resumed = Session.create(SessionId('resumed'), [...original.events])
|
||||
const resumedAgent = sessionAgent(resumed)
|
||||
vi.setSystemTime(BASE + 999)
|
||||
openMessageTurn(resumed, 2)
|
||||
@@ -273,7 +273,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('applies a positive interval across turns without sharing state between sessions', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const first = new Session(SessionId('interval-first'))
|
||||
const first = Session.create(SessionId('interval-first'))
|
||||
const firstAgent = sessionAgent(first, 'first-agent')
|
||||
openMessageTurn(first, 1)
|
||||
await fire(ctx, firstAgent, 1, 1)
|
||||
@@ -284,7 +284,7 @@ describe('durable step context', () => {
|
||||
const beforeSkip = first.events.length
|
||||
await fire(ctx, firstAgent, 2, 1)
|
||||
|
||||
const independent = new Session(SessionId('interval-independent'))
|
||||
const independent = Session.create(SessionId('interval-independent'))
|
||||
openMessageTurn(independent, 1)
|
||||
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
|
||||
|
||||
@@ -295,7 +295,7 @@ describe('durable step context', () => {
|
||||
|
||||
it('skips an already-aborted prompt submission', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('ordering'))
|
||||
const session = Session.create(SessionId('ordering'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('configuration and lifecycle', () => {
|
||||
process.env['TZ'] = 'Asia/Shanghai'
|
||||
const { ctx } = await mount()
|
||||
process.env['TZ'] = 'America/New_York'
|
||||
const session = new Session(SessionId('system-zone'))
|
||||
const session = Session.create(SessionId('system-zone'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -347,7 +347,7 @@ describe('configuration and lifecycle', () => {
|
||||
|
||||
it('removes its listener when the plugin fiber disposes', async () => {
|
||||
const { ctx, fiber } = await mount()
|
||||
const session = new Session(SessionId('dispose'))
|
||||
const session = Session.create(SessionId('dispose'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
@@ -443,7 +443,7 @@ describe('real Loader export path', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
|
||||
await ctx.plugin(plugin)
|
||||
const session = new Session(SessionId('loader'))
|
||||
const session = Session.create(SessionId('loader'))
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
|
||||
@@ -157,7 +157,7 @@ afterEach(() => {
|
||||
describe('tmux-context injection', () => {
|
||||
it('injects the tmux location on the first step of a turn', async () => {
|
||||
const { ctx } = await mount({}, true)
|
||||
const session = new Session(SessionId('first'))
|
||||
const session = Session.create(SessionId('first'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -176,7 +176,7 @@ describe('tmux-context injection', () => {
|
||||
|
||||
it('queries the pane this process runs in and matches its controlling tty', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('command'))
|
||||
const session = Session.create(SessionId('command'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -194,7 +194,7 @@ describe('tmux-context injection', () => {
|
||||
|
||||
it('does not run on later steps of a turn', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('later-step'))
|
||||
const session = Session.create(SessionId('later-step'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 2)
|
||||
@@ -205,7 +205,7 @@ describe('tmux-context injection', () => {
|
||||
|
||||
it('re-injects a new turn only when tmux state changed', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('change'))
|
||||
const session = Session.create(SessionId('change'))
|
||||
const agent = sessionAgent(session)
|
||||
|
||||
openMessageTurn(session, 1)
|
||||
@@ -233,7 +233,7 @@ describe('tmux-context injection', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
|
||||
const session = new Session(SessionId('interval'))
|
||||
const session = Session.create(SessionId('interval'))
|
||||
const agent = sessionAgent(session)
|
||||
|
||||
openMessageTurn(session, 1)
|
||||
@@ -260,7 +260,7 @@ describe('tmux-context injection', () => {
|
||||
describe('tmux-context prior-reading resilience', () => {
|
||||
it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('prior-non-text'))
|
||||
const session = Session.create(SessionId('prior-non-text'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -276,7 +276,7 @@ describe('tmux-context prior-reading resilience', () => {
|
||||
|
||||
it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('prior-single-line'))
|
||||
const session = Session.create(SessionId('prior-single-line'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -295,7 +295,7 @@ describe('tmux-context prior-reading resilience', () => {
|
||||
describe('tmux-context no-op paths', () => {
|
||||
it('is a no-op when no bash executor is mounted', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('no-bash'))
|
||||
const session = Session.create(SessionId('no-bash'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -306,7 +306,7 @@ describe('tmux-context no-op paths', () => {
|
||||
it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.result = runResult('', { exitCode: 1 })
|
||||
const session = new Session(SessionId('outside-tmux'))
|
||||
const session = Session.create(SessionId('outside-tmux'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -317,7 +317,7 @@ describe('tmux-context no-op paths', () => {
|
||||
it('is a no-op when the reading has the wrong field count', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.result = runResult('0\\t1\\tnode\n')
|
||||
const session = new Session(SessionId('malformed'))
|
||||
const session = Session.create(SessionId('malformed'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -328,7 +328,7 @@ describe('tmux-context no-op paths', () => {
|
||||
it('is a no-op when the pane id is empty', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
|
||||
const session = new Session(SessionId('empty-pane'))
|
||||
const session = Session.create(SessionId('empty-pane'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -340,7 +340,7 @@ describe('tmux-context no-op paths', () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.runError = new Error('bash executor unavailable')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn')
|
||||
const session = new Session(SessionId('run-rejected'))
|
||||
const session = Session.create(SessionId('run-rejected'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -353,7 +353,7 @@ describe('tmux-context no-op paths', () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.resolveError = new Error('command denied by policy')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn')
|
||||
const session = new Session(SessionId('resolve-rejected'))
|
||||
const session = Session.create(SessionId('resolve-rejected'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -367,7 +367,7 @@ describe('tmux-context no-op paths', () => {
|
||||
// Non-Error throw: the executor seam is typed, but a bad impl can reject with anything.
|
||||
bash.runError = 'spawn refused' as unknown as Error
|
||||
const warn = vi.spyOn(ctx.logger, 'warn')
|
||||
const session = new Session(SessionId('non-error-rejection'))
|
||||
const session = Session.create(SessionId('non-error-rejection'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
@@ -378,7 +378,7 @@ describe('tmux-context no-op paths', () => {
|
||||
|
||||
it('skips an already-aborted prompt submission', async () => {
|
||||
const { ctx } = await mount({}, true)
|
||||
const session = new Session(SessionId('ordering'))
|
||||
const session = Session.create(SessionId('ordering'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace
|
||||
|
||||
function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
const id = SessionId('s1')
|
||||
const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
|
||||
const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
|
||||
return {
|
||||
ctx: new Context(),
|
||||
id: SessionId('a1'),
|
||||
|
||||
@@ -2367,7 +2367,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'Session',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionAvailability',
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
AgentFactory,
|
||||
AgentHandle,
|
||||
AgentOptions,
|
||||
AgentSetup,
|
||||
CreateAgentOptions,
|
||||
ResumeAgentOptions,
|
||||
SessionStartSource,
|
||||
@@ -544,23 +545,40 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
|
||||
const published = (async () => {
|
||||
try {
|
||||
const setupCommit = await raceAbort(
|
||||
options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId,
|
||||
)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('startup')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
const published = this.setupAndPublish(
|
||||
ownerCtx,
|
||||
options.sessionId,
|
||||
session,
|
||||
options.agentOptions ?? {},
|
||||
options.setup,
|
||||
options.signal,
|
||||
'startup',
|
||||
)
|
||||
this.ownership.trackWrapper(published)
|
||||
return published
|
||||
}
|
||||
|
||||
/** Prepare one Agent around an acquired Session, run setup, and publish it. */
|
||||
private async setupAndPublish(
|
||||
ownerCtx: Context,
|
||||
id: SessionId,
|
||||
session: Session,
|
||||
agentOptions: AgentOptions,
|
||||
setup: AgentSetup | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
source: SessionStartSource,
|
||||
): Promise<AgentHandle> {
|
||||
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
|
||||
try {
|
||||
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish(source)
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an owned agent from the configured persistence service.
|
||||
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
|
||||
|
||||
@@ -551,7 +551,7 @@ describe('turn numbering continues across seeded sessions', () => {
|
||||
|
||||
describe('discriminated SessionEvent narrows without casts', () => {
|
||||
it('narrows event.data from event.type', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
const appended: SessionEvent = session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}',
|
||||
})
|
||||
|
||||
@@ -596,7 +596,7 @@ describe('request stability across the loop', () => {
|
||||
)!
|
||||
// Messages: the entered batch is logged after step/start, so rebuild the
|
||||
// complete dispatch prefix through a completely fresh Session.
|
||||
const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq)))
|
||||
const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq)))
|
||||
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
|
||||
|
||||
// Header: the latest request/header snapshot up to this step's dispatch
|
||||
|
||||
@@ -650,7 +650,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(a2.session.events.length).toBe(events1.length + 1)
|
||||
expect(a2.session.firstLiveSeq).toBe(events1.length)
|
||||
expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
|
||||
const replay = new Session(SessionId('replay'), events1)
|
||||
const replay = Session.create(SessionId('replay'), events1)
|
||||
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
|
||||
|
||||
// …and a new turn continues numbering (turn 2) with contiguous seqs.
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
|
||||
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const session = new Session(id)
|
||||
const session = Session.create(id)
|
||||
const agent: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
@@ -38,7 +38,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('rejects an invalid durable splice during reconstruction', () => {
|
||||
const session = new Session(SessionId('invalid-inbox-replay'))
|
||||
const session = Session.create(SessionId('invalid-inbox-replay'))
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn',
|
||||
start: 1,
|
||||
@@ -50,7 +50,7 @@ describe('Inbox', () => {
|
||||
})
|
||||
|
||||
it('replaces a pending message by identity across both lists', () => {
|
||||
const session = new Session(SessionId('replace-inbox'))
|
||||
const session = Session.create(SessionId('replace-inbox'))
|
||||
const inserted: UserMessage[] = []
|
||||
const discarded: UserMessage[] = []
|
||||
const inbox = new Inbox(session, {
|
||||
@@ -91,7 +91,7 @@ describe('Inbox', () => {
|
||||
})
|
||||
|
||||
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
|
||||
const session = new Session(SessionId('splice-inbox'))
|
||||
const session = Session.create(SessionId('splice-inbox'))
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
|
||||
const first = createUserMessage({
|
||||
content: [{ type: 'text', text: 'first' }],
|
||||
@@ -110,7 +110,7 @@ describe('Inbox', () => {
|
||||
})
|
||||
|
||||
it('clears both pending lists as durable cancellations', () => {
|
||||
const session = new Session(SessionId('clear-inbox'))
|
||||
const session = Session.create(SessionId('clear-inbox'))
|
||||
const discarded: UserMessage[] = []
|
||||
const inbox = new Inbox(session, {
|
||||
inserted: () => {},
|
||||
@@ -161,7 +161,7 @@ describe('AgentRegistry', () => {
|
||||
it('rejects an agent whose registry and session identities differ', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
|
||||
const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) })
|
||||
|
||||
expect(() => ctx.agents.enter(agent, undefined))
|
||||
.toThrow('agent id "agent-id" does not match session id "session-id"')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: bad0cc33d6ffec6e9b8abbc86cf3a0c79fd23b74
|
||||
README.zh.md: a28264c355362190d1fe15eaa9f0595c9d835cb2
|
||||
README.md: 0fde9a4657e46a574f4d0ab65fa3c2e217bd6403
|
||||
README.zh.md: 95cc4129e4988e480fca0b52c1b858758e3eb2f8
|
||||
@@ -34,7 +34,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
|
||||
|
||||
### Class: `Session`
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.create()` and detached replay or inspection sessions through `Session.create()`; the detached factory does not publish lifecycle events or bind the session to a fiber.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over the complete identified, frozen messages stored by those entries. Assistant messages preserve provider/model provenance and adapter-private replay state in their model source. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
@@ -48,6 +48,8 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit.
|
||||
|
||||
Session-event import separates ownership from message validation. `snapshotSessionEvent(event)` clones a borrowed event before validating and freezing its identified message. `adoptSessionEvent(event)` performs the same message work in place and returns the original event; callers may use it only when they transfer an exclusively owned object graph with no mutable child shared with another event.
|
||||
|
||||
### Chunk-row storage codec (`chunk-rows.ts`)
|
||||
|
||||
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only.
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
### 类:`Session`
|
||||
|
||||
普通类(不是 Cordis 服务)。通过 `ctx.sessions.create()` 创建。
|
||||
普通类(不是 Cordis 服务)。活跃会话通过 `ctx.sessions.create()` 创建,脱离态的回放或检查会话通过 `Session.create()` 创建;脱离态工厂不会发布生命周期事件,也不会将会话绑定到 fiber。
|
||||
|
||||
- `session.append(type, data, opts?)` 会为持久数据和 surface 元数据制作快照并冻结它们,校验标记形态、溯源信息、替换覆盖完整性,以及仅修改内容的单个 `tool/result` 重写,随后同步提交,再在彼此独立的失败收容下通知观察者。对已附加会话的重入追加会被拒绝,运行时检查也覆盖扩宽后的联合类型和已加载日志。
|
||||
- `session.deriveMessages()` 对每个新的 surface 条目只做一次增量投影,并返回一个新数组,其中包含这些条目存储的完整、带标识且冻结的消息。assistant 消息会在其模型来源中保留提供方/模型溯源信息及适配器私有回放状态。surface 重写会重建投影;不存在原始日志回退。
|
||||
@@ -48,6 +48,8 @@
|
||||
|
||||
持久值需要一种已接受的表示,不能先检查再二次读取。`isJsonValue(value)` 是布尔判断函数;`snapshotJsonValue(value)` 在一趟迭代中校验并复制普通值,无效输入返回 `undefined`,getter 抛出的异常则向外传播。快照辅助函数接受除 `-0` 外的有限 JSON 数值(JSON 会将其改写为 `0`)、稠密普通数组、普通对象或 null 原型对象;它会在规范化前拒绝循环引用、不支持的标量和特殊原型,同时不施加调用栈深度限制。
|
||||
|
||||
会话事件导入将所有权与消息校验分开处理。`snapshotSessionEvent(event)` 会先克隆借用的事件,再校验并冻结其中带标识的消息。`adoptSessionEvent(event)` 原地执行相同的消息处理并返回原事件;调用方只有在移交独占的对象图,且该对象图没有与其他事件共享可变子对象时,才可以使用此函数。
|
||||
|
||||
### 分片行存储编解码器(`chunk-rows.ts`)
|
||||
|
||||
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks`、`reasoning-chunks` 或 `tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0`/`time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq`/`time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture(测试前置数据)读取器(`dsh-llm-replay`、`dsh-acp-snapshot`)共享同一编解码器;后端默认启用的 `packChunks` 配置只控制写入。
|
||||
|
||||
@@ -103,17 +103,12 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
/** Validate and freeze one detached creation header in place. */
|
||||
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
const record = snapshot as Record<string, unknown>
|
||||
const record = input as Record<string, unknown>
|
||||
if (record.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
|
||||
}
|
||||
@@ -148,30 +143,51 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
return validateSessionHeader(id, snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an exclusively owned event and deeply freeze its identified message
|
||||
* without copying the event. The caller transfers an object graph that no
|
||||
* producer retains and that shares no mutable children with another event.
|
||||
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
|
||||
* @param event - exclusively owned event imported across a trusted boundary.
|
||||
* @returns the same event object with a validated, deeply frozen message.
|
||||
*/
|
||||
export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
|
||||
assertMessageEventShape(
|
||||
event,
|
||||
`session event at seq ${event.seq}`,
|
||||
)
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
deepFreeze(event.data)
|
||||
break
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
deepFreeze(event.data.message)
|
||||
break
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
|
||||
break
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one event while preserving deep immutability for its identified message.
|
||||
* @param event - event imported across a query or persistence boundary.
|
||||
* @returns a detached event snapshot with a validated, deeply frozen message.
|
||||
*/
|
||||
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
||||
const snapshot = structuredClone(event)
|
||||
assertMessageEventShape(
|
||||
snapshot,
|
||||
`session event at seq ${snapshot.seq}`,
|
||||
)
|
||||
switch (snapshot.type) {
|
||||
case 'user/message':
|
||||
deepFreeze(snapshot.data)
|
||||
break
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
deepFreeze(snapshot.data.message)
|
||||
break
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
|
||||
break
|
||||
}
|
||||
return snapshot
|
||||
return adoptSessionEvent(structuredClone(event))
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
@@ -360,7 +376,8 @@ const attachments = new WeakMap<Session, SessionEntry>()
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Plain class (not a Service) — create live instances via
|
||||
* `ctx.sessions.create()` and detached instances via {@link create}.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
* @typert object
|
||||
*/
|
||||
@@ -413,7 +430,19 @@ export class Session {
|
||||
*/
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
/**
|
||||
* Create a detached session by validating and snapshotting borrowed seed
|
||||
* events and storage metadata.
|
||||
* @param id - session identity.
|
||||
* @param seed - optional borrowed replay or fork events.
|
||||
* @param header - optional borrowed storage metadata.
|
||||
* @returns a detached session.
|
||||
*/
|
||||
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
|
||||
return new Session(id, seed, header)
|
||||
}
|
||||
|
||||
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed !== undefined) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
@@ -814,7 +843,7 @@ export class SessionStore extends Service {
|
||||
...meta?.origin === undefined ? {} : { origin: meta.origin },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
return Session.create(sessionId, seed, header)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,12 +16,12 @@ function userText(session: Session, text: string): void {
|
||||
|
||||
/** From-scratch oracle: replay the log into a fresh session and derive. */
|
||||
function scratch(session: Session): unknown {
|
||||
return new Session(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
|
||||
return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
|
||||
}
|
||||
|
||||
describe('derived-message cache', () => {
|
||||
it('stays deep-equal to a from-scratch replay derivation as the log grows', () => {
|
||||
const session = new Session(SessionId('cache-grow'))
|
||||
const session = Session.create(SessionId('cache-grow'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
userText(session, 'one')
|
||||
expect(session.deriveMessages()).toEqual(scratch(session))
|
||||
@@ -54,7 +54,7 @@ describe('derived-message cache', () => {
|
||||
})
|
||||
|
||||
it('rebuilds on a surface replace and still matches scratch', () => {
|
||||
const session = new Session(SessionId('cache-replace'))
|
||||
const session = Session.create(SessionId('cache-replace'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
userText(session, 'one')
|
||||
userText(session, 'two')
|
||||
@@ -72,7 +72,7 @@ describe('derived-message cache', () => {
|
||||
})
|
||||
|
||||
it('returns a fresh array per call: later appends never grow a held snapshot', () => {
|
||||
const session = new Session(SessionId('cache-snapshot'))
|
||||
const session = Session.create(SessionId('cache-snapshot'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
userText(session, 'one')
|
||||
const first = session.deriveMessages()
|
||||
@@ -89,7 +89,7 @@ describe('derived-message cache', () => {
|
||||
|
||||
describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
it('projects one appended event exactly as the full derivation projects its node', () => {
|
||||
const session = new Session(SessionId('per-event'))
|
||||
const session = Session.create(SessionId('per-event'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const event = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
@@ -99,7 +99,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
})
|
||||
|
||||
it('reuses the logged event\'s already frozen content', () => {
|
||||
const session = new Session(SessionId('per-event-clone'))
|
||||
const session = Session.create(SessionId('per-event-clone'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const event = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
|
||||
@@ -113,7 +113,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
})
|
||||
|
||||
it('projects null for events that produce no message (boundaries, empty assistant)', () => {
|
||||
const session = new Session(SessionId('per-event-null'))
|
||||
const session = Session.create(SessionId('per-event-null'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const boundary = session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(session.deriveEventMessage(boundary)).toBeNull()
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
it('rejects a detached Session object that is not live in ctx.sessions', async () => {
|
||||
const { sessions } = await setup()
|
||||
const detached = new Session(SessionId('detached'))
|
||||
const detached = Session.create(SessionId('detached'))
|
||||
|
||||
expect(() => sessions.fork(detached))
|
||||
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
|
||||
@@ -226,7 +226,7 @@ describe('SessionStore.fork', () => {
|
||||
it('rejects a stale Session object whose id is live on a different instance', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
ctx.sessions.create(SessionId('same-id'))
|
||||
const stale = new Session(SessionId('same-id'))
|
||||
const stale = Session.create(SessionId('same-id'))
|
||||
|
||||
expect(() => sessions.fork(stale))
|
||||
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
|
||||
|
||||
@@ -82,7 +82,7 @@ const logArb = fc.array(anyEventArb, { maxLength: 25 })
|
||||
|
||||
let counter = 0
|
||||
function build(events: Appendable[]): Session {
|
||||
const session = new Session(SessionId(`prop-${counter++}`))
|
||||
const session = Session.create(SessionId(`prop-${counter++}`))
|
||||
for (const e of events) {
|
||||
// Forward the generated intent verbatim; non-surface events carry none.
|
||||
if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
|
||||
@@ -110,7 +110,7 @@ describe('Session properties', () => {
|
||||
it('replay-from-seed reproduces the derivation identically', () => {
|
||||
fc.assert(fc.property(logArb, (events) => {
|
||||
const original = build(events)
|
||||
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
|
||||
const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
// Every explicit replay grows by exactly one log-only boundary.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
@@ -121,8 +121,8 @@ describe('Session properties', () => {
|
||||
it('replaying a log that already ends in end-seed adds no further marker', () => {
|
||||
fc.assert(fc.property(logArb, (events) => {
|
||||
const original = build(events)
|
||||
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
|
||||
const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
|
||||
const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events])
|
||||
const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events])
|
||||
// Lazy resume makes browsing a pickup, so this must not grow per open.
|
||||
expect(twice.events).toEqual(once.events)
|
||||
}))
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('foldRequestHeader', () => {
|
||||
})
|
||||
|
||||
it('takes the latest full snapshot and skips unrelated events', () => {
|
||||
const session = new Session(SessionId('fold'))
|
||||
const session = Session.create(SessionId('fold'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -91,9 +91,9 @@ describe('legacy request-header format', () => {
|
||||
const legacy = [{
|
||||
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
|
||||
}] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
|
||||
expect(() => Session.create(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
|
||||
|
||||
const session = new Session(SessionId('legacy-append-delta'))
|
||||
const session = Session.create(SessionId('legacy-append-delta'))
|
||||
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
||||
expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
|
||||
.toThrow(/unsupported legacy request\/header-delta/)
|
||||
@@ -104,10 +104,10 @@ describe('legacy request-header format', () => {
|
||||
const legacy = [{
|
||||
type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
|
||||
}] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
|
||||
expect(() => Session.create(SessionId('legacy-seed-reason'), legacy))
|
||||
.toThrow('unsupported legacy request/header reason "fallback"')
|
||||
|
||||
const session = new Session(SessionId('legacy-append-reason'))
|
||||
const session = Session.create(SessionId('legacy-append-reason'))
|
||||
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
||||
expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
|
||||
.toThrow('unsupported legacy request/header reason "fallback"')
|
||||
@@ -130,13 +130,13 @@ describe('Session.requestContext', () => {
|
||||
}
|
||||
|
||||
it('reads undefined before any record exists', () => {
|
||||
expect(new Session(SessionId('no-capacity')).requestContext()).toBeUndefined()
|
||||
expect(Session.create(SessionId('no-capacity')).requestContext()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('folds a seeded log on first read, taking the last record', () => {
|
||||
// The fold watermark starts at 0 with the seed already in the log, so the
|
||||
// first read must consume the whole seed rather than skip it.
|
||||
const session = new Session(SessionId('seeded-capacity'), seedWith(
|
||||
const session = Session.create(SessionId('seeded-capacity'), seedWith(
|
||||
CAPACITY,
|
||||
{ ...CAPACITY, model: 'later', contextWindow: 256_000 },
|
||||
))
|
||||
@@ -144,7 +144,7 @@ describe('Session.requestContext', () => {
|
||||
})
|
||||
|
||||
it('advances incrementally across appends and skips unrelated events', () => {
|
||||
const session = new Session(SessionId('incremental-capacity'), seedWith(CAPACITY))
|
||||
const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('todo/write', { todos: [] })
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
@@ -155,7 +155,7 @@ describe('Session.requestContext', () => {
|
||||
})
|
||||
|
||||
it('folds a batch appended between two reads', () => {
|
||||
const session = new Session(SessionId('batched-capacity'), seedWith(CAPACITY))
|
||||
const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
|
||||
session.append('todo/write', { todos: [] })
|
||||
@@ -164,7 +164,7 @@ describe('Session.requestContext', () => {
|
||||
})
|
||||
|
||||
it('exposes a frozen record so a reader cannot desync later comparisons', () => {
|
||||
const session = new Session(SessionId('frozen-capacity'), seedWith(CAPACITY))
|
||||
const session = Session.create(SessionId('frozen-capacity'), seedWith(CAPACITY))
|
||||
const held = session.requestContext()
|
||||
if (held === undefined) throw new Error('expected a folded capacity record')
|
||||
expect(Object.isFrozen(held)).toBe(true)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, {
|
||||
adoptSessionEvent,
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionEvent,
|
||||
@@ -13,7 +14,7 @@ import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurf
|
||||
|
||||
describe('Session', () => {
|
||||
it('finds the latest closed turn that entered a model step', () => {
|
||||
const session = new Session(SessionId('last-message-turn'))
|
||||
const session = Session.create(SessionId('last-message-turn'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
|
||||
|
||||
@@ -29,7 +30,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('exposes one stable readonly surface view', () => {
|
||||
const session = new Session(SessionId('surface-view'))
|
||||
const session = Session.create(SessionId('surface-view'))
|
||||
const surface = session.surface
|
||||
|
||||
expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
|
||||
@@ -37,7 +38,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('derives message history from the event log', () => {
|
||||
const session = new Session(SessionId('s1'))
|
||||
const session = Session.create(SessionId('s1'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
|
||||
@@ -77,7 +78,7 @@ describe('Session', () => {
|
||||
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'))
|
||||
const session = Session.create(SessionId('s1'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
|
||||
@@ -88,10 +89,10 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('round-trips an aborted turn with its cancellation cause', () => {
|
||||
const session = new Session(SessionId('aborted'))
|
||||
const session = Session.create(SessionId('aborted'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
expect(replayed.events.slice(0, -1)).toEqual(session.events)
|
||||
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
|
||||
@@ -99,7 +100,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('renders injected-context and user messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
const session = Session.create(SessionId('s2'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
@@ -117,7 +118,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('keeps the exact identified context message in durable history and projection', () => {
|
||||
const session = new Session(SessionId('s2-raw'))
|
||||
const session = Session.create(SessionId('s2-raw'))
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
@@ -130,7 +131,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('replays identically from a seeded event log', () => {
|
||||
const original = new Session(SessionId('s3'))
|
||||
const original = Session.create(SessionId('s3'))
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
@@ -148,7 +149,7 @@ describe('Session', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
const replayed = Session.create(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
// The seed verbatim, plus the end-seed event the constructor appends.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
@@ -157,16 +158,16 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('marks an explicitly empty seed without marking a fresh session', () => {
|
||||
const fresh = new Session(SessionId('fresh-empty'))
|
||||
const fresh = Session.create(SessionId('fresh-empty'))
|
||||
expect(fresh.events).toEqual([])
|
||||
|
||||
const resumed = new Session(SessionId('resumed-empty'), [])
|
||||
const resumed = Session.create(SessionId('resumed-empty'), [])
|
||||
expect(resumed.firstLiveSeq).toBe(0)
|
||||
expect(resumed.events).toMatchObject([
|
||||
{ type: 'session/end-seed', seq: 0, data: {} },
|
||||
])
|
||||
|
||||
const reopened = new Session(SessionId('reopened-empty'), resumed.events)
|
||||
const reopened = Session.create(SessionId('reopened-empty'), resumed.events)
|
||||
expect(reopened.firstLiveSeq).toBe(1)
|
||||
expect(reopened.events).toEqual(resumed.events)
|
||||
})
|
||||
@@ -176,7 +177,7 @@ describe('Session', () => {
|
||||
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]))
|
||||
expect(() => Session.create(SessionId('old-header'), [requestHeader]))
|
||||
.toThrow('seed request/header at index 0 lacks provider/model')
|
||||
|
||||
const assistantMessage = {
|
||||
@@ -184,20 +185,20 @@ describe('Session', () => {
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent
|
||||
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
|
||||
expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
|
||||
.toThrow('seed assistant/message at index 0 lacks an identified message')
|
||||
|
||||
const malformedHeader = {
|
||||
type: 'request/header', seq: 0, time: 1,
|
||||
data: { header: 'old-header' },
|
||||
} as unknown as SessionEvent
|
||||
expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
|
||||
expect(() => Session.create(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.slice(0, 1))
|
||||
expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
|
||||
.toEqual([unrelatedPrimitiveData])
|
||||
})
|
||||
|
||||
@@ -312,7 +313,7 @@ describe('Session', () => {
|
||||
|
||||
for (const { name, event, message } of invalid) {
|
||||
expect(
|
||||
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
|
||||
() => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
|
||||
name,
|
||||
).toThrow(message)
|
||||
}
|
||||
@@ -348,6 +349,45 @@ describe('Session', () => {
|
||||
.toEqual([{ type: 'plugin-block', value: 1 }])
|
||||
})
|
||||
|
||||
it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
|
||||
const owned = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
id: 'owned-message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'owned' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
} as SessionEvent<'user/message'>
|
||||
expect(adoptSessionEvent(owned)).toBe(owned)
|
||||
expect(Object.isFrozen(owned.data)).toBe(true)
|
||||
expect(Object.isFrozen(owned.data.content)).toBe(true)
|
||||
|
||||
const source = structuredClone(owned)
|
||||
const snapshot = snapshotSessionEvent(source)
|
||||
expect(snapshot).not.toBe(source)
|
||||
expect(snapshot.data).not.toBe(source.data)
|
||||
expect(snapshot.data.content).not.toBe(source.data.content)
|
||||
})
|
||||
|
||||
it('validates message shape before adopting ownership', () => {
|
||||
const malformed = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: {
|
||||
id: 'wrong-role',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
} as unknown as SessionEvent
|
||||
expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
|
||||
})
|
||||
|
||||
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
|
||||
const valid = {
|
||||
type: 'request/header',
|
||||
@@ -364,7 +404,7 @@ describe('Session', () => {
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
|
||||
expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0])
|
||||
.toEqual(valid)
|
||||
|
||||
for (const reasoningEffort of ['', 1]) {
|
||||
@@ -372,7 +412,7 @@ describe('Session', () => {
|
||||
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]))
|
||||
expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
|
||||
}
|
||||
})
|
||||
@@ -394,7 +434,7 @@ describe('Session', () => {
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
|
||||
expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
|
||||
|
||||
for (const adapterDefaults of [
|
||||
null,
|
||||
@@ -406,13 +446,13 @@ describe('Session', () => {
|
||||
const invalid = structuredClone(valid) as unknown as SessionEvent
|
||||
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
||||
invalid.data.header.adapterDefaults = adapterDefaults as never
|
||||
expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid]))
|
||||
expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has invalid adapterDefaults')
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
||||
const session = new Session(SessionId('s4'))
|
||||
const session = Session.create(SessionId('s4'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -446,7 +486,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
|
||||
const session = new Session(SessionId('s5'))
|
||||
const session = Session.create(SessionId('s5'))
|
||||
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
|
||||
expect(bad(1n)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
|
||||
@@ -473,7 +513,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
||||
const session = new Session(SessionId('s5b'))
|
||||
const session = Session.create(SessionId('s5b'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
// A widened SessionEventType bypasses the overload's conditional requirement,
|
||||
// so the runtime guard must still reject the missing surface marker.
|
||||
@@ -487,7 +527,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('accepts dense arrays and nested plain objects', () => {
|
||||
const session = new Session(SessionId('s6'))
|
||||
const session = Session.create(SessionId('s6'))
|
||||
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()
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
@@ -498,7 +538,7 @@ describe('Session', () => {
|
||||
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[]
|
||||
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
|
||||
expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a non-contiguous seq', () => {
|
||||
@@ -506,7 +546,7 @@ describe('Session', () => {
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ 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/)
|
||||
expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
|
||||
@@ -521,7 +561,7 @@ describe('Session', () => {
|
||||
}) },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
||||
expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
||||
})
|
||||
|
||||
it('accepts a well-formed contiguous serializable seed', () => {
|
||||
@@ -532,7 +572,7 @@ describe('Session', () => {
|
||||
}), surfaceOp: 'append' as const },
|
||||
{ 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)
|
||||
const session = Session.create(SessionId('seed-ok'), goodSeed)
|
||||
expect(session.events.slice(0, 3)).toEqual(goodSeed)
|
||||
expect(session.firstLiveSeq).toBe(3)
|
||||
})
|
||||
@@ -555,7 +595,7 @@ describe('Session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const session = new Session(SessionId('seed-entry-snapshot'), seed)
|
||||
const session = Session.create(SessionId('seed-entry-snapshot'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events.slice(0, 1)).toEqual([accepted])
|
||||
@@ -572,7 +612,7 @@ describe('Session', () => {
|
||||
})
|
||||
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-nested-drift'), seed)
|
||||
const session = Session.create(SessionId('seed-nested-drift'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
|
||||
@@ -589,7 +629,7 @@ describe('Session', () => {
|
||||
surfaceOp: { op: 'replace', start: 1n, end: 2 },
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
|
||||
expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
|
||||
.toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
@@ -609,7 +649,7 @@ describe('Session', () => {
|
||||
surfaceOp: new ReplaceOp(),
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
|
||||
expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
|
||||
.toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
@@ -622,7 +662,7 @@ describe('Session', () => {
|
||||
}
|
||||
const seed: SessionEvent[] = [new SeedEvent()]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
|
||||
expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
@@ -634,7 +674,7 @@ describe('Session', () => {
|
||||
data: { turn: 1 },
|
||||
}) as unknown as SessionEvent
|
||||
|
||||
const session = new Session(SessionId('seed-null-prototype'), [event])
|
||||
const session = Session.create(SessionId('seed-null-prototype'), [event])
|
||||
|
||||
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
|
||||
})
|
||||
@@ -667,7 +707,7 @@ describe('Session', () => {
|
||||
sourceEventSeqs: [0],
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-unstable-metadata'), seed)
|
||||
const session = Session.create(SessionId('seed-unstable-metadata'), seed)
|
||||
const event = session.events[1]!
|
||||
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
|
||||
|
||||
@@ -704,7 +744,7 @@ describe('Session', () => {
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
try {
|
||||
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
.toThrow(`invalid seed event at index 1: ${expected}`)
|
||||
} finally {
|
||||
hasOwn.mockRestore()
|
||||
@@ -721,7 +761,7 @@ describe('Session', () => {
|
||||
}, surfaceOp: 'append' as const },
|
||||
{ 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)
|
||||
const session = Session.create(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.
|
||||
@@ -734,7 +774,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
|
||||
const session = new Session(SessionId('append-snapshot'))
|
||||
const session = Session.create(SessionId('append-snapshot'))
|
||||
const data = {
|
||||
id: MessageId('append-input'),
|
||||
role: 'user' as const,
|
||||
@@ -754,7 +794,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('reads a nested append-data getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-nested-drift'))
|
||||
const session = Session.create(SessionId('append-nested-drift'))
|
||||
let reads = 0
|
||||
const data = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
@@ -772,7 +812,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects non-JSON surface metadata before appending the event', () => {
|
||||
const session = new Session(SessionId('append-bad-metadata'))
|
||||
const session = Session.create(SessionId('append-bad-metadata'))
|
||||
|
||||
expect(() => session.append(
|
||||
'user/message',
|
||||
@@ -790,7 +830,7 @@ describe('Session', () => {
|
||||
readonly start = 0
|
||||
readonly end = 0
|
||||
}
|
||||
const session = new Session(SessionId('append-exotic-metadata'))
|
||||
const session = Session.create(SessionId('append-exotic-metadata'))
|
||||
|
||||
expect(() => session.append(
|
||||
'user/message',
|
||||
@@ -803,7 +843,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-unstable-metadata'))
|
||||
const session = Session.create(SessionId('append-unstable-metadata'))
|
||||
const source = session.append(
|
||||
'user/message',
|
||||
createUserMessage({
|
||||
@@ -834,7 +874,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects invalid plain surface metadata shapes at append', () => {
|
||||
const session = new Session(SessionId('append-invalid-surface-shape'))
|
||||
const session = Session.create(SessionId('append-invalid-surface-shape'))
|
||||
const appendRaw = session.append.bind(session) as unknown as (
|
||||
type: SessionEventType,
|
||||
data: unknown,
|
||||
@@ -855,7 +895,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('rejects surface metadata on non-surface append and seed events', () => {
|
||||
const session = new Session(SessionId('non-surface-metadata'))
|
||||
const session = Session.create(SessionId('non-surface-metadata'))
|
||||
const appendRaw = session.append.bind(session) as unknown as (
|
||||
type: SessionEventType,
|
||||
data: unknown,
|
||||
@@ -867,7 +907,7 @@ describe('Session', () => {
|
||||
{ turn: 1 },
|
||||
{ surfaceOp: 'append' },
|
||||
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
||||
expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
@@ -878,7 +918,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('deep-freezes seeded and appended event snapshots', () => {
|
||||
const seeded = new Session(SessionId('seed-frozen'), [{
|
||||
const seeded = Session.create(SessionId('seed-frozen'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
@@ -890,7 +930,7 @@ describe('Session', () => {
|
||||
expect(Object.isFrozen(seededEvent.data)).toBe(true)
|
||||
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
const appended = new Session(SessionId('append-frozen'))
|
||||
const appended = Session.create(SessionId('append-frozen'))
|
||||
const appendedEvent = appended.append('todo/write', {
|
||||
todos: [{ content: 'first', status: 'pending' }],
|
||||
})
|
||||
@@ -902,7 +942,7 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
||||
const session = new Session(SessionId('events-snapshot'))
|
||||
const session = Session.create(SessionId('events-snapshot'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const before = session.events
|
||||
const beforeEvent = before[0]!
|
||||
@@ -931,7 +971,7 @@ describe('Session', () => {
|
||||
seedLength: 2,
|
||||
}
|
||||
|
||||
const session = new Session(SessionId('header-owned'), undefined, input)
|
||||
const session = Session.create(SessionId('header-owned'), undefined, input)
|
||||
input.cwd = '/caller-mutated'
|
||||
|
||||
expect(session.header).toEqual({
|
||||
@@ -956,15 +996,15 @@ describe('Session', () => {
|
||||
readonly createdAt = 123
|
||||
}
|
||||
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
||||
expect(() => Session.create(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, {
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('other'),
|
||||
createdAt: 123,
|
||||
@@ -991,7 +1031,7 @@ describe('Session', () => {
|
||||
]
|
||||
|
||||
for (const { header, error } of cases) {
|
||||
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
|
||||
expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1015,7 +1055,7 @@ describe('Session', () => {
|
||||
]
|
||||
|
||||
for (const [index, event] of cases.entries()) {
|
||||
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
.toThrow(/invalid event envelope/)
|
||||
}
|
||||
})
|
||||
@@ -1103,7 +1143,7 @@ describe('SessionStore', () => {
|
||||
const secondCtx = new Context()
|
||||
await firstCtx.plugin(SessionStore)
|
||||
await secondCtx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('owned-key'))
|
||||
const session = Session.create(SessionId('owned-key'))
|
||||
const detachFirst = firstCtx.sessions.enter(session)
|
||||
|
||||
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
|
||||
@@ -1260,7 +1300,7 @@ describe('SessionStore', () => {
|
||||
})
|
||||
|
||||
it('a bare Session() constructed without the store still exposes a current-version header', () => {
|
||||
const session = new Session(SessionId('bare'))
|
||||
const session = Session.create(SessionId('bare'))
|
||||
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
})
|
||||
@@ -1578,7 +1618,7 @@ describe('SessionStore', () => {
|
||||
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 replacement = Session.create(SessionId('replacement-disposed'))
|
||||
const heard: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/disposed') args[0] = replacement
|
||||
@@ -1596,7 +1636,7 @@ describe('SessionStore', () => {
|
||||
|
||||
describe('todo/write event', () => {
|
||||
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
||||
const session = new Session(SessionId('t1'))
|
||||
const session = Session.create(SessionId('t1'))
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
@@ -1618,7 +1658,7 @@ describe('todo/write event', () => {
|
||||
})
|
||||
|
||||
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
||||
const session = new Session(SessionId('t2'))
|
||||
const session = Session.create(SessionId('t2'))
|
||||
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'first', status: 'completed' },
|
||||
@@ -1633,7 +1673,7 @@ describe('todo/write event', () => {
|
||||
})
|
||||
|
||||
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
|
||||
const session = new Session(SessionId('t3'))
|
||||
const session = Session.create(SessionId('t3'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -1646,12 +1686,12 @@ describe('todo/write event', () => {
|
||||
})
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
const original = new Session(SessionId('t4'))
|
||||
const original = Session.create(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Seeding a non-surface event with no surfaceOp must not throw.
|
||||
const replayed = new Session(SessionId('t4-replay'), [...original.events])
|
||||
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
function surfaceSession(): Session {
|
||||
const s = new Session(SessionId('ss'))
|
||||
const s = Session.create(SessionId('ss'))
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
|
||||
@@ -240,7 +240,7 @@ describe('foldSurface tool-result rewrites', () => {
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
const s = Session.create(SessionId('shared-fold'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -284,7 +284,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('does not retain fold-only replacement history in incremental state', () => {
|
||||
const s = new Session(SessionId('incremental-state'))
|
||||
const s = Session.create(SessionId('incremental-state'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -315,12 +315,12 @@ describe('SurfaceManager', () => {
|
||||
] as SessionEvent[]
|
||||
|
||||
expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
|
||||
expect(() => new Session(SessionId('shared-fold-invalid'), events))
|
||||
expect(() => Session.create(SessionId('shared-fold-invalid'), events))
|
||||
.toThrow(/start seq 42 not found/)
|
||||
})
|
||||
|
||||
it('leaves incremental state unchanged when candidate validation fails', () => {
|
||||
const s = new Session(SessionId('atomic-validation'))
|
||||
const s = Session.create(SessionId('atomic-validation'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -397,7 +397,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('empty surface yields empty nodes', () => {
|
||||
const s = new Session(SessionId('empty'))
|
||||
const s = Session.create(SessionId('empty'))
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
@@ -431,7 +431,7 @@ describe('SurfaceManager', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const replayed = new Session(SessionId('replay'), [...original.events])
|
||||
const replayed = Session.create(SessionId('replay'), [...original.events])
|
||||
expect(replayed.surface.nodes).toEqual([1, 2, 4])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
})
|
||||
@@ -456,7 +456,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('replace with both ends at real nodes splices only the range', () => {
|
||||
const s = new Session(SessionId('range'))
|
||||
const s = Session.create(SessionId('range'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }) // seq 0
|
||||
@@ -485,7 +485,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('single-node replacement (start === end)', () => {
|
||||
const s = new Session(SessionId('single'))
|
||||
const s = Session.create(SessionId('single'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }) // seq 0
|
||||
@@ -511,7 +511,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('throws when replace start is not found', () => {
|
||||
const s = new Session(SessionId('bad-start'))
|
||||
const s = Session.create(SessionId('bad-start'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }) // seq 0
|
||||
@@ -532,7 +532,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('throws when replace end is not found', () => {
|
||||
const s = new Session(SessionId('bad-end'))
|
||||
const s = Session.create(SessionId('bad-end'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }) // seq 0
|
||||
@@ -553,7 +553,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('throws when start is after end', () => {
|
||||
const s = new Session(SessionId('reversed'))
|
||||
const s = Session.create(SessionId('reversed'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }) // seq 0
|
||||
@@ -578,7 +578,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
|
||||
const s = new Session(SessionId('immutable'))
|
||||
const s = Session.create(SessionId('immutable'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -602,7 +602,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('replace starting at non-head position preserves surrounding order', () => {
|
||||
const s = new Session(SessionId('mid-replace'))
|
||||
const s = Session.create(SessionId('mid-replace'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }) // seq 0
|
||||
@@ -631,7 +631,7 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
|
||||
const s = new Session(SessionId('immutable-op'))
|
||||
const s = Session.create(SessionId('immutable-op'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -666,7 +666,7 @@ describe('deriveMessages with surface', () => {
|
||||
})
|
||||
|
||||
it('surface path skips non-surface events (chunks, boundaries)', () => {
|
||||
const s = new Session(SessionId('filter'))
|
||||
const s = Session.create(SessionId('filter'))
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
||||
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
|
||||
@@ -690,7 +690,7 @@ describe('deriveMessages with surface', () => {
|
||||
})
|
||||
|
||||
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
|
||||
const s = new Session(SessionId('compacted'))
|
||||
const s = Session.create(SessionId('compacted'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -712,7 +712,7 @@ describe('deriveMessages with surface', () => {
|
||||
})
|
||||
|
||||
it('injected-context and user messages appear on surface', () => {
|
||||
const s = new Session(SessionId('ctx'))
|
||||
const s = Session.create(SessionId('ctx'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -729,7 +729,7 @@ describe('deriveMessages with surface', () => {
|
||||
|
||||
describe('Session.append surface opts', () => {
|
||||
it('records sourceEventSeqs and surfaceOp on the event', () => {
|
||||
const s = new Session(SessionId('opts'))
|
||||
const s = Session.create(SessionId('opts'))
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
const event = s.append('assistant/message',
|
||||
@@ -774,20 +774,20 @@ describe('Session.append surface opts', () => {
|
||||
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const s = new Session(SessionId('nomessage'), seed)
|
||||
const s = Session.create(SessionId('nomessage'), seed)
|
||||
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
|
||||
expect(s.deriveMessages()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a non-surface event carries no surface fields', () => {
|
||||
const s = new Session(SessionId('noopts'))
|
||||
const s = Session.create(SessionId('noopts'))
|
||||
s.append('turn/start', { turn: 1 })
|
||||
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
|
||||
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaceOp primitives are not cloned (they are immutable)', () => {
|
||||
const s = new Session(SessionId('prim'))
|
||||
const s = Session.create(SessionId('prim'))
|
||||
const event = s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
@@ -897,7 +897,7 @@ describe('surface type guards', () => {
|
||||
|
||||
describe('SurfaceManager.replaceGeneration', () => {
|
||||
it('folds the pending log delta on access and counts replaces', () => {
|
||||
const s = new Session(SessionId('gen'))
|
||||
const s = Session.create(SessionId('gen'))
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
|
||||
|
||||
@@ -1278,7 +1278,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
return Promise.resolve(observedDepth)
|
||||
},
|
||||
}))
|
||||
const session = new Session(SessionId('deep-code-arguments'))
|
||||
const session = Session.create(SessionId('deep-code-arguments'))
|
||||
const agent = { session } as Agent
|
||||
runtime.behavior = async (request) => {
|
||||
let nested: JsonValue = 'leaf'
|
||||
@@ -1441,7 +1441,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
})
|
||||
|
||||
it('a tool/code-dispatch event never derives a model message', () => {
|
||||
const session = new Session(SessionId('code-mode-derive'))
|
||||
const session = Session.create(SessionId('code-mode-derive'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -28,7 +28,7 @@ afterEach(async () => {
|
||||
function agent(ctx: Context, cwd: string): Agent {
|
||||
const id = SessionId(`str-replace-editor-owner-${callNumber}`)
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
const value: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
|
||||
@@ -54,7 +54,7 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
|
||||
/** Build a registry-compatible agent around a fresh session. */
|
||||
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
|
||||
return stubAgentForSession(new Session(SessionId(rawId), seed))
|
||||
return stubAgentForSession(Session.create(SessionId(rawId), seed))
|
||||
}
|
||||
|
||||
async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
|
||||
@@ -235,7 +235,7 @@ describe('GoalService creation and replay', () => {
|
||||
const { ctx, agent } = await harness()
|
||||
// A same-id agent backed by a different session object — the live-instance
|
||||
// check must reject it even though the ids match.
|
||||
const impostor = stubAgentForSession(new Session(agent.id)).agent
|
||||
const impostor = stubAgentForSession(Session.create(agent.id)).agent
|
||||
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
|
||||
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_AGENT_NOT_LIVE',
|
||||
@@ -516,7 +516,7 @@ describe('goal replay validation', () => {
|
||||
}
|
||||
|
||||
function oneChange(change: GoalChangeMeta) {
|
||||
const session = new Session(SessionId(`validation-${Math.random()}`))
|
||||
const session = Session.create(SessionId(`validation-${Math.random()}`))
|
||||
appendChange(session, change)
|
||||
return session.events
|
||||
}
|
||||
@@ -547,7 +547,7 @@ describe('goal replay validation', () => {
|
||||
|
||||
it('keeps durable goal state independent from inbox changes', () => {
|
||||
const change = snapshotChange()
|
||||
const session = new Session(SessionId('inbox-independent-change'))
|
||||
const session = Session.create(SessionId('inbox-independent-change'))
|
||||
appendChange(session, change)
|
||||
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
|
||||
const message = createUserMessage({
|
||||
@@ -561,7 +561,7 @@ describe('goal replay validation', () => {
|
||||
})
|
||||
|
||||
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
|
||||
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
|
||||
const session = Session.create(SessionId(`validation-pair-${Math.random()}`))
|
||||
appendChange(session, first)
|
||||
appendChange(session, second)
|
||||
return foldGoal(session.events)
|
||||
@@ -570,7 +570,7 @@ describe('goal replay validation', () => {
|
||||
it('ignores unrelated metadata and non-goal round sources', () => {
|
||||
expect(decodeGoalChange(undefined)).toBeUndefined()
|
||||
expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
|
||||
const session = new Session(SessionId('unrelated'))
|
||||
const session = Session.create(SessionId('unrelated'))
|
||||
appendInjection(session, createUserMessage({
|
||||
content: [{ type: 'text', text: 'other' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
@@ -588,7 +588,7 @@ describe('goal replay validation', () => {
|
||||
|
||||
it('rejects rounds attributed to another goal', () => {
|
||||
const change = snapshotChange()
|
||||
const session = new Session(SessionId('other-goal-round'), oneChange(change))
|
||||
const session = Session.create(SessionId('other-goal-round'), oneChange(change))
|
||||
appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1)
|
||||
expect(() => foldGoal(session.events)).toThrow('not the next admitted round')
|
||||
})
|
||||
@@ -660,7 +660,7 @@ describe('goal replay validation', () => {
|
||||
roundsStarted: 2,
|
||||
goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 },
|
||||
})
|
||||
const session = new Session(SessionId('exhausted-resume'))
|
||||
const session = Session.create(SessionId('exhausted-resume'))
|
||||
appendChange(session, base)
|
||||
appendRound(session, base.goal, 1)
|
||||
appendRound(session, base.goal, 2)
|
||||
@@ -686,7 +686,7 @@ describe('goal replay validation', () => {
|
||||
createdAt: 20,
|
||||
updatedAt: 20,
|
||||
})
|
||||
const completedSession = new Session(SessionId('reuse-complete'))
|
||||
const completedSession = Session.create(SessionId('reuse-complete'))
|
||||
appendChange(completedSession, base)
|
||||
appendChange(completedSession, complete)
|
||||
appendChange(completedSession, sameCurrentId)
|
||||
@@ -698,7 +698,7 @@ describe('goal replay validation', () => {
|
||||
updatedAt: 20,
|
||||
})
|
||||
const secondComplete = mutation(second, 'complete', 'complete')
|
||||
const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent'))
|
||||
const nonAdjacentReuse = Session.create(SessionId('reuse-non-adjacent'))
|
||||
appendChange(nonAdjacentReuse, base)
|
||||
appendChange(nonAdjacentReuse, complete)
|
||||
appendChange(nonAdjacentReuse, second)
|
||||
@@ -709,7 +709,7 @@ describe('goal replay validation', () => {
|
||||
const clear: GoalChangeMeta = {
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11,
|
||||
}
|
||||
const clearedSession = new Session(SessionId('reuse-clear'))
|
||||
const clearedSession = Session.create(SessionId('reuse-clear'))
|
||||
appendChange(clearedSession, base)
|
||||
appendChange(clearedSession, clear)
|
||||
appendChange(clearedSession, sameCurrentId)
|
||||
@@ -717,7 +717,7 @@ describe('goal replay validation', () => {
|
||||
})
|
||||
|
||||
it('rejects non-positive goal round sources', () => {
|
||||
const session = new Session(SessionId('goal-source-without-meta'))
|
||||
const session = Session.create(SessionId('goal-source-without-meta'))
|
||||
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn })
|
||||
@@ -763,7 +763,7 @@ describe('goal replay validation', () => {
|
||||
|
||||
it('folds a clear tombstone after a snapshot', () => {
|
||||
const change = snapshotChange()
|
||||
const session = new Session(SessionId('fold-clear'), oneChange(change))
|
||||
const session = Session.create(SessionId('fold-clear'), oneChange(change))
|
||||
const clear: GoalChangeMeta = {
|
||||
kind: 'goal/change',
|
||||
version: 1,
|
||||
|
||||
@@ -23,7 +23,7 @@ interface StubAgent {
|
||||
|
||||
/** Build one registry-compatible live agent whose injections enter the durable inbox. */
|
||||
function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
const session = supplied ?? new Session(SessionId(rawId))
|
||||
const session = supplied ?? Session.create(SessionId(rawId))
|
||||
let status: AgentStatus = 'running'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
@@ -258,7 +258,7 @@ describe('goal tool execution authority', () => {
|
||||
const created = ctx.goals.create(root.agent, { objective: 'resume the fork' })
|
||||
closeTurn(root, originalTurn)
|
||||
const forkId = SessionId('goal-tool-resumed-fork')
|
||||
const forkSession = new Session(forkId, root.session.events, {
|
||||
const forkSession = Session.create(forkId, root.session.events, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: forkId,
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -9,7 +9,7 @@ function output(over: Partial<HookOutput> = {}): HookOutput {
|
||||
|
||||
describe('hook/* session events', () => {
|
||||
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
|
||||
|
||||
const ev = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
@@ -22,7 +22,7 @@ describe('hook/* session events', () => {
|
||||
})
|
||||
|
||||
it('omits matcher when absent (match-all hook)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
|
||||
|
||||
const ev = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
@@ -32,7 +32,7 @@ describe('hook/* session events', () => {
|
||||
})
|
||||
|
||||
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
|
||||
@@ -43,7 +43,7 @@ describe('hook/* session events', () => {
|
||||
}
|
||||
|
||||
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
|
||||
const session2 = new Session(SessionId('s2'))
|
||||
const session2 = Session.create(SessionId('s2'))
|
||||
appendHookResult(session2, {
|
||||
turn: 1, point: 'Stop', handlerId: 'h3',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
|
||||
@@ -57,7 +57,7 @@ describe('hook/* session events', () => {
|
||||
})
|
||||
|
||||
it('the decision falls back to stop on continue:false, else pass', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
|
||||
// An explicit decision wins over the continue:false fallback.
|
||||
@@ -70,7 +70,7 @@ describe('hook/* session events', () => {
|
||||
})
|
||||
|
||||
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'long',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
|
||||
@@ -82,7 +82,7 @@ describe('hook/* session events', () => {
|
||||
})
|
||||
|
||||
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'edge',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
|
||||
@@ -94,7 +94,7 @@ describe('hook/* session events', () => {
|
||||
})
|
||||
|
||||
it('an invoked/result pair correlates by handlerId', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('hook-protocol invariants', () => {
|
||||
|
||||
it('adopts a bare session first observed through publication', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('bare-hook-session'))
|
||||
const session = Session.create(SessionId('bare-hook-session'))
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('TokenMeterService pricing', () => {
|
||||
|
||||
it('returns a detached deeply immutable empty measurement', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('empty'))
|
||||
const session = Session.create(SessionId('empty'))
|
||||
const result = service.measure(session)
|
||||
expect(result).toEqual({
|
||||
logRevision: 0,
|
||||
@@ -168,7 +168,7 @@ describe('TokenMeterService pricing', () => {
|
||||
|
||||
it('keeps an earlier unified snapshot detached from later replay', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('detached'))
|
||||
const session = Session.create(SessionId('detached'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -200,7 +200,7 @@ describe('TokenMeterService pricing', () => {
|
||||
|
||||
it('prices header, tools, and surface when no reusable usage exists', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('heuristic'))
|
||||
const session = Session.create(SessionId('heuristic'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'question' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -218,7 +218,7 @@ describe('TokenMeterService pricing', () => {
|
||||
|
||||
it('keeps request-header overrides out of the returned surface', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('override-surface'))
|
||||
const session = Session.create(SessionId('override-surface'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'question' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -246,7 +246,7 @@ describe('replay anchors and surface folds', () => {
|
||||
|
||||
it('uses disjoint provider usage and signed durable-output rewrites', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('usage'))
|
||||
const session = Session.create(SessionId('usage'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'before' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -267,7 +267,7 @@ describe('replay anchors and surface folds', () => {
|
||||
|
||||
it('selects a heuristic anchor when provider usage would undercut its scale', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('low-usage-anchor'))
|
||||
const session = Session.create(SessionId('low-usage-anchor'))
|
||||
const system = 'system context'
|
||||
const requestHeader = header('deepseek-v4-flash', { system })
|
||||
appendSuccessfulCall(session, requestHeader, {
|
||||
@@ -297,7 +297,7 @@ describe('replay anchors and surface folds', () => {
|
||||
|
||||
it('uses an estimated anchor when provider usage is absent', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('missing-usage'))
|
||||
const session = Session.create(SessionId('missing-usage'))
|
||||
appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), {
|
||||
providerText: 'provider',
|
||||
durableText: 'rewritten',
|
||||
@@ -314,8 +314,8 @@ describe('replay anchors and surface folds', () => {
|
||||
})
|
||||
|
||||
it('distinguishes explicit empty provenance from absent legacy provenance', () => {
|
||||
const explicit = new Session(SessionId('explicit-empty'))
|
||||
const legacy = new Session(SessionId('legacy-absent'))
|
||||
const explicit = Session.create(SessionId('explicit-empty'))
|
||||
const legacy = Session.create(SessionId('legacy-absent'))
|
||||
appendSuccessfulCall(explicit, header('deepseek-v4-flash'), {
|
||||
durableText: 'listener injected text',
|
||||
providerText: '',
|
||||
@@ -335,7 +335,7 @@ describe('replay anchors and surface folds', () => {
|
||||
|
||||
it('keeps only the latest successful request anchor across model switches', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('switch'))
|
||||
const session = Session.create(SessionId('switch'))
|
||||
const alphaHeader = header('alpha', { system: 'same envelope' })
|
||||
appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' })
|
||||
expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 34 })
|
||||
@@ -356,7 +356,7 @@ describe('replay anchors and surface folds', () => {
|
||||
|
||||
it('invalidates usage for any canonical envelope change or explicit override', () => {
|
||||
const service = meter()
|
||||
const session = new Session(SessionId('envelope'))
|
||||
const session = Session.create(SessionId('envelope'))
|
||||
const anchoredHeader = header('deepseek-v4-flash', { system: 'one' })
|
||||
appendSuccessfulCall(session, anchoredHeader, { usage: USAGE })
|
||||
expect(service.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
|
||||
@@ -375,7 +375,7 @@ describe('replay anchors and surface folds', () => {
|
||||
})
|
||||
|
||||
it('folds the latest full header snapshot into the effective envelope', () => {
|
||||
const session = new Session(SessionId('header-snapshot'))
|
||||
const session = Session.create(SessionId('header-snapshot'))
|
||||
appendHeader(session, header('deepseek-v4-flash'))
|
||||
session.append('request/header', {
|
||||
header: header('deepseek-v4-pro'),
|
||||
@@ -388,7 +388,7 @@ describe('replay anchors and surface folds', () => {
|
||||
|
||||
it('replays seeded append and replace operations with signed deltas', () => {
|
||||
const service = meter()
|
||||
const original = new Session(SessionId('surface-original'))
|
||||
const original = Session.create(SessionId('surface-original'))
|
||||
appendSuccessfulCall(original, header('deepseek-v4-flash'), {
|
||||
usage: USAGE,
|
||||
providerText: 'long provider answer '.repeat(100),
|
||||
@@ -397,7 +397,7 @@ describe('replay anchors and surface folds', () => {
|
||||
content: [{ type: 'text', text: 'new tail' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const seeded = new Session(SessionId('surface-seeded'), original.events)
|
||||
const seeded = Session.create(SessionId('surface-seeded'), original.events)
|
||||
const before = service.measure(seeded)
|
||||
expect(before.nodes).toHaveLength(2)
|
||||
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||
@@ -423,7 +423,7 @@ describe('replay anchors and surface folds', () => {
|
||||
})
|
||||
|
||||
it('prices an empty assistant surface anchor as zero', () => {
|
||||
const session = new Session(SessionId('empty-assistant'))
|
||||
const session = Session.create(SessionId('empty-assistant'))
|
||||
appendSuccessfulCall(session, header('deepseek-v4-flash'), {
|
||||
providerText: '',
|
||||
durableText: '',
|
||||
@@ -444,7 +444,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
}
|
||||
|
||||
it('rejects an assistant without its step boundary transactionally', () => {
|
||||
const session = new Session(SessionId('bad-step'))
|
||||
const session = Session.create(SessionId('bad-step'))
|
||||
appendHeader(session, header('deepseek-v4-flash'))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
@@ -462,7 +462,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
})
|
||||
|
||||
it('clears completed step boundaries and rejects overlapping or late step events', () => {
|
||||
const overlapping = new Session(SessionId('overlapping-step'))
|
||||
const overlapping = Session.create(SessionId('overlapping-step'))
|
||||
overlapping.append('step/start', { turn: 1, step: 1 })
|
||||
overlapping.append('step/start', { turn: 1, step: 2 })
|
||||
expectRepeatedFailure(
|
||||
@@ -471,7 +471,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
/arrived before turn 1\/step 1 ended/,
|
||||
)
|
||||
|
||||
const late = new Session(SessionId('late-assistant'))
|
||||
const late = Session.create(SessionId('late-assistant'))
|
||||
late.append('step/start', { turn: 1, step: 1 })
|
||||
appendHeader(late, header('deepseek-v4-flash'))
|
||||
late.append('step/end', { turn: 1, step: 1 })
|
||||
@@ -493,7 +493,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
/no matching step\/start/,
|
||||
)
|
||||
|
||||
const mismatchedEnd = new Session(SessionId('mismatched-end'))
|
||||
const mismatchedEnd = Session.create(SessionId('mismatched-end'))
|
||||
mismatchedEnd.append('step/start', { turn: 1, step: 1 })
|
||||
mismatchedEnd.append('step/end', { turn: 1, step: 2 })
|
||||
expectRepeatedFailure(
|
||||
@@ -532,7 +532,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
},
|
||||
]
|
||||
for (const testCase of cases) {
|
||||
const session = new Session(SessionId(`bad-source-${testCase.name}`))
|
||||
const session = Session.create(SessionId(`bad-source-${testCase.name}`))
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
appendHeader(session, header('deepseek-v4-flash'))
|
||||
const sourceEventSeqs = testCase.appendSource(session)
|
||||
@@ -554,7 +554,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
})
|
||||
|
||||
it('rejects repeated and non-earlier assistant provenance', () => {
|
||||
const duplicate = new Session(SessionId('duplicate-source'))
|
||||
const duplicate = Session.create(SessionId('duplicate-source'))
|
||||
duplicate.append('step/start', { turn: 1, step: 1 })
|
||||
appendHeader(duplicate, header('deepseek-v4-flash'))
|
||||
const source = duplicate.append('assistant/chunk', {
|
||||
@@ -584,7 +584,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
})
|
||||
expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/)
|
||||
|
||||
const future = new Session(SessionId('future-source'))
|
||||
const future = Session.create(SessionId('future-source'))
|
||||
future.append('step/start', { turn: 1, step: 1 })
|
||||
appendHeader(future, header('deepseek-v4-flash'))
|
||||
appendUnchecked(future, {
|
||||
@@ -611,7 +611,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
})
|
||||
|
||||
it('does not partially apply a malformed assistant replacement', () => {
|
||||
const session = new Session(SessionId('transactional-replace'))
|
||||
const session = Session.create(SessionId('transactional-replace'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'head' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -638,7 +638,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
})
|
||||
|
||||
it('rejects corrupt replacement ranges without advancing the replay cursor', () => {
|
||||
const session = new Session(SessionId('bad-replace'))
|
||||
const session = Session.create(SessionId('bad-replace'))
|
||||
const head = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'head' }],
|
||||
source: { kind: 'user' },
|
||||
|
||||
@@ -26,7 +26,7 @@ function emitTurnStart(ctx: Context, session: Session): void {
|
||||
describe('plan-mode stream invariants', () => {
|
||||
it('accepts either boolean state', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('plan-state'))
|
||||
const session = Session.create(SessionId('plan-state'))
|
||||
emitTurnStart(ctx, session)
|
||||
expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow()
|
||||
@@ -37,7 +37,7 @@ describe('plan-mode stream invariants', () => {
|
||||
|
||||
it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId(`invalid-${String(active)}`))
|
||||
const session = Session.create(SessionId(`invalid-${String(active)}`))
|
||||
emitTurnStart(ctx, session)
|
||||
expect(() => { ctx.emit('session/event', session, event(active)) })
|
||||
.toThrow(/expected a boolean/)
|
||||
@@ -51,7 +51,7 @@ describe('plan-mode stream invariants', () => {
|
||||
|
||||
it('ignores unrelated dispatches and session events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('unrelated'))
|
||||
const session = Session.create(SessionId('unrelated'))
|
||||
expect(() => {
|
||||
ctx.emit('tools/change')
|
||||
ctx.emit('session/event', session, {
|
||||
|
||||
@@ -28,7 +28,7 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
|
||||
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
|
||||
// A live store session when a store is mounted (the command executor logs
|
||||
// lifecycle events through it); bare otherwise (fold/tool-only benches).
|
||||
const session = new Session(SessionId(id))
|
||||
const session = Session.create(SessionId(id))
|
||||
const agent = {
|
||||
id: SessionId(id),
|
||||
session,
|
||||
@@ -167,7 +167,7 @@ describe('resolveConfig', () => {
|
||||
|
||||
describe('foldPlanMode', () => {
|
||||
it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
|
||||
const session = new Session(SessionId('fold'))
|
||||
const session = Session.create(SessionId('fold'))
|
||||
expect(foldPlanMode(session.events)).toBe(false)
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('plan/mode', { active: false })
|
||||
@@ -176,7 +176,7 @@ describe('foldPlanMode', () => {
|
||||
})
|
||||
|
||||
it('folds a prefix when `end` is given', () => {
|
||||
const session = new Session(SessionId('fold-prefix'))
|
||||
const session = Session.create(SessionId('fold-prefix'))
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('plan/mode', { active: false })
|
||||
expect(foldPlanMode(session.events, 1)).toBe(true)
|
||||
|
||||
@@ -40,7 +40,7 @@ function config(): ResolvedConfig {
|
||||
|
||||
function agent(ctx: Context, cwd?: string): Agent {
|
||||
const id = SessionId('agent')
|
||||
const session = new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
|
||||
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
|
||||
return {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
status: 'idle', ctx,
|
||||
|
||||
@@ -33,7 +33,7 @@ class PassthroughSandbox extends SandboxProvider {
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = new Session(id)
|
||||
const session = Session.create(id)
|
||||
return {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
status: 'idle', ctx: scope.ctx,
|
||||
|
||||
@@ -21,7 +21,7 @@ const ptyServiceDisposers = new WeakMap<Context, () => Promise<void>>()
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const session = new Session(id)
|
||||
const session = Session.create(id)
|
||||
const agent: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
|
||||
@@ -38,7 +38,7 @@ class PassthroughSandbox extends SandboxProvider {
|
||||
function agent(ctx: Context, cwd: string): Agent {
|
||||
const id = SessionId('persistent-bash-loader-agent')
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
const value: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
|
||||
@@ -29,7 +29,7 @@ afterEach(async () => {
|
||||
function agent(ctx: Context, cwd: string | undefined): Agent {
|
||||
const id = SessionId(`persistent-bash-owner-${callNumber}`)
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = new Session(id, [], {
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: 0,
|
||||
|
||||
@@ -38,7 +38,7 @@ class PassthroughSandbox extends SandboxProvider {
|
||||
function agent(ctx: Context): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const id = SessionId('pty-loader-agent')
|
||||
const session = new Session(id)
|
||||
const session = Session.create(id)
|
||||
const value: Agent = {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
status: 'idle', ctx: scope.ctx,
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
|
||||
function fakeAgent(ctx: Context, rawId: string): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const id = SessionId(rawId)
|
||||
const session = new Session(id)
|
||||
const session = Session.create(id)
|
||||
const agent: Agent = {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
status: 'idle', ctx: scope.ctx,
|
||||
|
||||
@@ -22,7 +22,7 @@ async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'dange
|
||||
|
||||
function session(id: string, cwd?: string): Session {
|
||||
const sessionId = SessionId(id)
|
||||
return new Session(sessionId, undefined, {
|
||||
return Session.create(sessionId, undefined, {
|
||||
version: 0,
|
||||
id: sessionId,
|
||||
createdAt: 0,
|
||||
@@ -195,7 +195,7 @@ describe('sandbox:policy request context', () => {
|
||||
it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => {
|
||||
const active = session('sess-resume', '/projects/current')
|
||||
setSandboxMode(active, 'workspace-write')
|
||||
const resumed = new Session(active.id, active.events, active.header)
|
||||
const resumed = Session.create(active.id, active.events, active.header)
|
||||
const ctx = await promptMounted({ mode: 'read-only' })
|
||||
|
||||
expect(await policyContext(ctx, resumed)).toContain('workspace-write')
|
||||
@@ -209,7 +209,7 @@ describe('the sandbox/mode session kit', () => {
|
||||
})
|
||||
|
||||
it('effectiveSandboxMode folds to the last switch, or undefined without one', () => {
|
||||
const session = new Session(SessionId('sess-fold'))
|
||||
const session = Session.create(SessionId('sess-fold'))
|
||||
expect(effectiveSandboxMode(session.events)).toBeUndefined()
|
||||
setSandboxMode(session, 'workspace-write')
|
||||
setSandboxMode(session, 'read-only')
|
||||
@@ -217,7 +217,7 @@ describe('the sandbox/mode session kit', () => {
|
||||
})
|
||||
|
||||
it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
|
||||
const session = new Session(SessionId('sess-write'))
|
||||
const session = Session.create(SessionId('sess-write'))
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
const modeEvents = session.events.filter(e => e.type === 'sandbox/mode')
|
||||
expect(modeEvents).toHaveLength(1)
|
||||
|
||||
@@ -255,7 +255,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
}
|
||||
expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
|
||||
expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
|
||||
const resumed = new Session(m.id, loaded.events, loaded.meta)
|
||||
const resumed = Session.create(m.id, loaded.events, loaded.meta)
|
||||
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
|
||||
expect(resumedResult?.content[0]).toMatchObject({
|
||||
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
|
||||
|
||||
@@ -450,7 +450,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
])
|
||||
expect(messages.every(message => Object.isFrozen(message))).toBe(true)
|
||||
|
||||
const resumed = new Session(id, snapshot.events, snapshot.meta)
|
||||
const resumed = Session.create(id, snapshot.events, snapshot.meta)
|
||||
expect(resumed.deriveMessages().map(message => message.id)).toEqual([
|
||||
`legacy-message:${id}:1`,
|
||||
`legacy-message:${id}:3`,
|
||||
@@ -522,7 +522,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
},
|
||||
])
|
||||
|
||||
const resumed = new Session(id, snapshot.events, snapshot.meta)
|
||||
const resumed = Session.create(id, snapshot.events, snapshot.meta)
|
||||
expect(resumed.deriveMessages().map(message => message.content)).toEqual([
|
||||
[{ type: 'text', text: 'old prompt' }],
|
||||
[{ type: 'text', text: 'old steering' }],
|
||||
|
||||
@@ -143,7 +143,7 @@ export abstract class SessionQueryService extends Service {
|
||||
*/
|
||||
async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
new Session(sessionId, loaded.events, loaded.header)
|
||||
Session.create(sessionId, loaded.events, loaded.header)
|
||||
return {
|
||||
session: structuredClone(loaded.header),
|
||||
events: loaded.events.map(snapshotSessionEvent),
|
||||
|
||||
@@ -31,7 +31,7 @@ async function settle(): Promise<void> {
|
||||
|
||||
describe('all-messages LLM title provider', () => {
|
||||
it('includes seeded history and the latest prompt while inheriting the logged request route', async () => {
|
||||
const seeded = new Session(SessionId('seed-source'))
|
||||
const seeded = Session.create(SessionId('seed-source'))
|
||||
seeded.append('turn/start', { turn: 1 })
|
||||
const inherited = seeded.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' },
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('first-message LLM title provider', () => {
|
||||
providerPlugin.apply(ctx, LLM_CONFIG)
|
||||
|
||||
await expect(registered!.generate({
|
||||
session: new Session(SessionId('empty-first-provider')),
|
||||
session: Session.create(SessionId('empty-first-provider')),
|
||||
messages: [],
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toThrow(/requires one human message/)
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('SessionTitleService.rename', () => {
|
||||
const session = ctx.sessions.create(SessionId('rename-reject'))
|
||||
expect(() => ctx.sessionTitle.rename(session, ' [31m ')).toThrow(/visible characters/)
|
||||
|
||||
expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name'))
|
||||
expect(() => ctx.sessionTitle.rename(Session.create(SessionId('detached')), 'name'))
|
||||
.toThrow(/not live in this store/)
|
||||
})
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
|
||||
await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached'))))
|
||||
await expect(withProvider.sessionTitle.refresh(Session.create(SessionId('detached'))))
|
||||
.rejects.toThrow(/not live in this store/)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('already cancelled'))
|
||||
@@ -163,7 +163,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
|
||||
it('shares one fallback across concurrent refreshes', async () => {
|
||||
const ctx = await setup()
|
||||
const seed = new Session(SessionId('fallback-concurrency-seed'))
|
||||
const seed = Session.create(SessionId('fallback-concurrency-seed'))
|
||||
seed.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('SessionTitleService', () => {
|
||||
})
|
||||
|
||||
it('folds the latest title event during replay', () => {
|
||||
const seed = new Session(SessionId('source'))
|
||||
const seed = Session.create(SessionId('source'))
|
||||
seed.append('session/title', {
|
||||
title: 'Earlier',
|
||||
messageSeqs: [1],
|
||||
|
||||
@@ -38,7 +38,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
|
||||
|
||||
function agentForCwd(cwd: string): Agent {
|
||||
const id = SessionId(`tool-skill-${cwd}`)
|
||||
const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
return {
|
||||
ctx: new Context(),
|
||||
id,
|
||||
@@ -324,7 +324,7 @@ describe('dsh-tool-skill', () => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
const session = new Session(SessionId('incomplete-prefix'))
|
||||
const session = Session.create(SessionId('incomplete-prefix'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
|
||||
@@ -340,7 +340,7 @@ describe('dsh-tool-skill', () => {
|
||||
it('records an empty baseline across repeated step observations', async () => {
|
||||
const home = await tempDir('tool-empty-step')
|
||||
const ctx = await setup(home)
|
||||
const session = new Session(SessionId('empty-step'))
|
||||
const session = Session.create(SessionId('empty-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
|
||||
@@ -359,7 +359,7 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'First body.',
|
||||
})
|
||||
const session = new Session(SessionId('proposed-catalog'))
|
||||
const session = Session.create(SessionId('proposed-catalog'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
@@ -393,7 +393,7 @@ describe('dsh-tool-skill', () => {
|
||||
it('removes a stale proposed catalog before the first empty baseline', async () => {
|
||||
const home = await tempDir('tool-proposed-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
const session = new Session(SessionId('proposed-empty-catalog'))
|
||||
const session = Session.create(SessionId('proposed-empty-catalog'))
|
||||
const stale = createUserMessage({
|
||||
content: catalogContent(['- `stale-skill`: Stale skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
@@ -413,7 +413,7 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'First body.',
|
||||
})
|
||||
const session = new Session(SessionId('matching-proposal'))
|
||||
const session = Session.create(SessionId('matching-proposal'))
|
||||
const proposed = createUserMessage({
|
||||
content: catalogContent(['- `first-skill`: First skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
@@ -433,7 +433,7 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'First body.',
|
||||
})
|
||||
const session = new Session(SessionId('dynamic-catalog'))
|
||||
const session = Session.create(SessionId('dynamic-catalog'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
|
||||
@@ -477,7 +477,7 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'Resumed body.',
|
||||
})
|
||||
const session = new Session(SessionId('catalog-resume'))
|
||||
const session = Session.create(SessionId('catalog-resume'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -516,7 +516,7 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'First body.',
|
||||
})
|
||||
const session = new Session(SessionId('catalog-compaction'))
|
||||
const session = Session.create(SessionId('catalog-compaction'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
|
||||
@@ -541,7 +541,7 @@ describe('dsh-tool-skill', () => {
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'body-skill', 'Stable description', 'First body.')
|
||||
const ctx = await setup(home)
|
||||
const session = new Session(SessionId('body-refresh'))
|
||||
const session = Session.create(SessionId('body-refresh'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
|
||||
@@ -571,7 +571,7 @@ describe('dsh-tool-skill', () => {
|
||||
source: 'runtime',
|
||||
content: 'Stable body.',
|
||||
})
|
||||
const session = new Session(SessionId('incomplete-catalog'))
|
||||
const session = Session.create(SessionId('incomplete-catalog'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
|
||||
@@ -595,7 +595,7 @@ describe('dsh-tool-skill', () => {
|
||||
const home = await tempDir('tool-restricted-catalog')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
|
||||
const session = new Session(SessionId('restricted-catalog'))
|
||||
const session = Session.create(SessionId('restricted-catalog'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
const { scope } = await mintAgentScope(ctx, agent)
|
||||
|
||||
@@ -25,7 +25,7 @@ export function seedDescriptorTurn(
|
||||
seed: readonly SessionEvent[] | undefined,
|
||||
descriptor: SubagentDescriptorData,
|
||||
): SessionEvent[] {
|
||||
const staged = new Session(childId, seed)
|
||||
const staged = Session.create(childId, seed)
|
||||
staged.append('subagent/descriptor', descriptor)
|
||||
return [...staged.events]
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const session = new Session(id)
|
||||
const session = Session.create(id)
|
||||
const agent = {
|
||||
id,
|
||||
options: {},
|
||||
|
||||
@@ -22,7 +22,7 @@ const testToolSignal = new AbortController().signal
|
||||
|
||||
/** A parent Agent backed by a real Session — the tool reads `agent.session`. */
|
||||
function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
const session = Session.create(SessionId(id))
|
||||
return { id: SessionId(id), session } as unknown as Agent & { session: Session }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */
|
||||
import { z } from 'zod'
|
||||
|
||||
const Payload$schema = z.object({
|
||||
'name': z.string(),
|
||||
'count': z.number().optional(),
|
||||
}).describe('Runtime-validating data root.')
|
||||
|
||||
export const Payload = Payload$schema
|
||||
|
||||
export const TYPERT = {
|
||||
package: '@fixture/host',
|
||||
face: 'host',
|
||||
schemas: [
|
||||
{ name: 'Payload', schema: Payload },
|
||||
],
|
||||
model: {
|
||||
"services": [
|
||||
{
|
||||
"description": "Service exported only through a non-default alias.",
|
||||
"summary": "Service exported only through a non-default alias.",
|
||||
"tags": [],
|
||||
"jsDoc": "/** Service exported only through a non-default alias. */",
|
||||
"key": "aliased",
|
||||
"exportName": "PublicAliasedService",
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "ready",
|
||||
"signature": "ready(): boolean",
|
||||
"summary": "Report readiness.",
|
||||
"jsDoc": "/** Report readiness. */"
|
||||
}
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
{
|
||||
"description": "Service exported only through the package default.",
|
||||
"summary": "Service exported only through the package default.",
|
||||
"tags": [],
|
||||
"jsDoc": "/** Service exported only through the package default. */",
|
||||
"key": "defaultOnly",
|
||||
"exportName": "default",
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "ready",
|
||||
"signature": "ready(): boolean",
|
||||
"summary": "Report readiness.",
|
||||
"jsDoc": "/** Report readiness. */"
|
||||
}
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
{
|
||||
"description": "Fixture service with generic, mapped, and truly external boundary types.",
|
||||
"summary": "Fixture service with generic, mapped, and truly external boundary types.",
|
||||
"tags": [],
|
||||
"jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */",
|
||||
"key": "demo",
|
||||
"exportName": "DemoService",
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "inspect",
|
||||
"signature": "inspect(agent: Agent<{ ready: true }>, flags: Flags<Payload>): Present<Payload>",
|
||||
"summary": "Inspect one agent without flattening its generic state.",
|
||||
"jsDoc": "/** Inspect one agent without flattening its generic state. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "acceptsExternal",
|
||||
"signature": "acceptsExternal(schema: ZodType<string>): void",
|
||||
"summary": "Keep an npm-owned type as External.",
|
||||
"jsDoc": "/** Keep an npm-owned type as External. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "setPhase",
|
||||
"signature": "setPhase(phase: AgentPhase): void",
|
||||
"summary": "Accept a developer-authored enum without flattening it.",
|
||||
"jsDoc": "/** Accept a developer-authored enum without flattening it. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "inspectSyntax",
|
||||
"signature": "inspectSyntax(zoo: SyntaxZoo): void",
|
||||
"summary": "Exercise every retained type-graph shape from a public boundary.",
|
||||
"jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "inspectAsync",
|
||||
"signature": "async inspectAsync(zoo: SyntaxZoo): Promise<void>",
|
||||
"summary": "Preserve async source metadata without changing its type signature.",
|
||||
"jsDoc": "/** Preserve async source metadata without changing its type signature. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "destructure",
|
||||
"signature": "destructure({ name }: Payload, [suffix]: [string]): string",
|
||||
"summary": "Retain an authored binding-pattern parameter.",
|
||||
"jsDoc": "/** Retain an authored binding-pattern parameter. */"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "AbstractEntity",
|
||||
"declaration": "export abstract class AbstractEntity implements Entity {\n abstract readonly id: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Added",
|
||||
"declaration": "export type Added<Value> = { readonly [Key in keyof Value]?: Value[Key] };"
|
||||
},
|
||||
{
|
||||
"name": "Agent",
|
||||
"declaration": "export class Agent<State extends object = { ready: boolean; }> implements Entity {\n readonly id: string;\n state: State;\n get label(): string;\n set label(value: string);\n run<Value>(input: Box<Value>): Promise<Present<Value>>;\n}"
|
||||
},
|
||||
{
|
||||
"name": "AgentPhase",
|
||||
"declaration": "export enum AgentPhase {\n Unknown,\n Idle = 'idle',\n Running = 'running',\n}"
|
||||
},
|
||||
{
|
||||
"name": "Box",
|
||||
"declaration": "export interface Box<T> {\n readonly value: T;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Callable",
|
||||
"declaration": "export interface Callable {\n (value: string): number;\n new (value: string): Entity;\n readonly [key: string]: unknown;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Entity",
|
||||
"declaration": "export interface Entity {\n readonly id: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Flags",
|
||||
"declaration": "export type Flags<T> = { readonly [K in keyof T]?: boolean };"
|
||||
},
|
||||
{
|
||||
"name": "Guards",
|
||||
"declaration": "export interface Guards {\n isEntity(value: unknown): value is Entity;\n isFluent(): this is Guards;\n assertEntity(value: unknown): asserts value is Entity;\n assertPresent(value: unknown): asserts value;\n fluent(): this;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Payload",
|
||||
"declaration": "export interface Payload {\n name: string;\n count?: number;\n}"
|
||||
},
|
||||
{
|
||||
"name": "PlainMap",
|
||||
"declaration": "export type PlainMap<Value> = { [Key in keyof Value]: Value[Key] };"
|
||||
},
|
||||
{
|
||||
"name": "Present",
|
||||
"declaration": "export type Present<T> = T extends null | undefined ? never : T;"
|
||||
},
|
||||
{
|
||||
"name": "Recursive",
|
||||
"declaration": "export interface Recursive extends Box<string> {\n readonly next?: Recursive;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Remapped",
|
||||
"declaration": "export type Remapped<Value> = { -readonly [Key in keyof Value as `get${Capitalize<string & Key>}`]-?: Value[Key] };"
|
||||
},
|
||||
{
|
||||
"name": "Result",
|
||||
"declaration": "export type Result<Value> = Value extends (...arguments_: never[]) => infer Output ? Output : never;"
|
||||
},
|
||||
{
|
||||
"name": "Route",
|
||||
"declaration": "export type Route<From extends string, To extends string> = `/${From}/to/${To}/end`;"
|
||||
},
|
||||
{
|
||||
"name": "StringResult",
|
||||
"declaration": "export type StringResult<Value> = Value extends readonly [infer Output extends string] ? Output : never;"
|
||||
},
|
||||
{
|
||||
"name": "SyntaxZoo",
|
||||
"declaration": "export interface SyntaxZoo {\n anyValue: any;\n bigintValue: bigint;\n parenthesized: (Entity | null);\n literals: 1 | 1n | -2 | -2n | false | `fixed`;\n readonly uniqueToken: unique symbol;\n intersection: Entity & { active: boolean; };\n array: string[];\n tuple: [head: string, count?: number, ...tail: boolean[]];\n unnamedTuple: [string?, ...number[]];\n readonlyTuple: readonly [string, number];\n object: { readonly value?: string; 'quoted-name': number; 1: boolean; ['computed']: symbol; invoke?(input: number): void; };\n callback: <Value extends Entity = Entity>(this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise<Value>;\n constCallback: <const Value extends readonly string[]>(value: Value) => Value;\n factory: new <Value extends Entity>(value: Value) => Value;\n abstractFactory: abstract new (id: string) => AbstractEntity;\n indexed: Payload['name'];\n inferred: Result<() => string>;\n constrainedInfer: StringResult<['value']>;\n topic: Topic<'ready'>;\n route: Route<'source', 'target'>;\n query: typeof phaseOrder;\n instantiatedQuery: typeof genericFactory<string>;\n imported: import('zod').ZodType<string>;\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType<string>;\n importedModule: typeof import('zod');\n process: NodeJS.Process;\n callable: Callable;\n guards: Guards;\n variance: Variance<Entity, Payload, Box<string>>;\n plainMap: PlainMap<Payload>;\n remapped: Remapped<Payload>;\n added: Added<Payload>;\n abstractEntity: AbstractEntity;\n recursive: Recursive;\n tagOnly: TagOnly;\n unpunctuated: Unpunctuated;\n}"
|
||||
},
|
||||
{
|
||||
"name": "TagOnly",
|
||||
"declaration": "export interface TagOnly {\n readonly value: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Topic",
|
||||
"declaration": "export type Topic<Name extends string> = `demo/${Name}`;"
|
||||
},
|
||||
{
|
||||
"name": "Unpunctuated",
|
||||
"declaration": "export interface Unpunctuated {\n readonly value: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Variance",
|
||||
"declaration": "export interface Variance<in Input, out Output, in out State> {\n consume: (input: Input) => void;\n readonly produce: () => Output;\n state: State;\n}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tags": [],
|
||||
"name": "demo/property",
|
||||
"signature": "'demo/property'(payload: Payload): void"
|
||||
},
|
||||
{
|
||||
"description": "A generic fixture event.",
|
||||
"summary": "A generic fixture event.",
|
||||
"tags": [
|
||||
{
|
||||
"name": "param",
|
||||
"argument": "agent",
|
||||
"comment": "- emitting agent.",
|
||||
"text": "@param agent - emitting agent.\n *"
|
||||
},
|
||||
{
|
||||
"name": "param",
|
||||
"argument": "payload",
|
||||
"comment": "- event payload.",
|
||||
"text": "@param payload - event payload.\n *"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"comment": "emit",
|
||||
"text": "@mode emit"
|
||||
}
|
||||
],
|
||||
"jsDoc": "/**\n * A generic fixture event.\n * @param agent - emitting agent.\n * @param payload - event payload.\n * @mode emit\n */",
|
||||
"name": "demo/ready",
|
||||
"mode": "emit",
|
||||
"signature": "'demo/ready'(agent: Agent<{ ready: true; }>, payload: Box<Payload>): void"
|
||||
},
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "mode",
|
||||
"comment": "serial",
|
||||
"text": "@mode serial"
|
||||
}
|
||||
],
|
||||
"jsDoc": "/** @mode serial */",
|
||||
"name": "demo/serial-property",
|
||||
"mode": "serial",
|
||||
"signature": "'demo/serial-property'(payload: Payload): void"
|
||||
},
|
||||
{
|
||||
"tags": [],
|
||||
"name": "demo/unmodeled",
|
||||
"signature": "'demo/unmodeled'(): void"
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"description": "Reference-passed capability object.",
|
||||
"summary": "Reference-passed capability object.",
|
||||
"tags": [
|
||||
{
|
||||
"name": "typert",
|
||||
"comment": "object",
|
||||
"text": "@typert object"
|
||||
}
|
||||
],
|
||||
"jsDoc": "/**\n * Reference-passed capability object.\n * @typert object\n */",
|
||||
"name": "Agent",
|
||||
"exportName": "Agent",
|
||||
"members": [
|
||||
{
|
||||
"kind": "property",
|
||||
"name": "id",
|
||||
"signature": "readonly id: string"
|
||||
},
|
||||
{
|
||||
"kind": "property",
|
||||
"name": "state",
|
||||
"signature": "state: State"
|
||||
},
|
||||
{
|
||||
"kind": "getter",
|
||||
"name": "label",
|
||||
"signature": "get label(): string",
|
||||
"summary": "Read the public display label.",
|
||||
"jsDoc": "/** Read the public display label. */"
|
||||
},
|
||||
{
|
||||
"kind": "setter",
|
||||
"name": "label",
|
||||
"signature": "set label(value: string)",
|
||||
"summary": "Accept a public display label.",
|
||||
"jsDoc": "/** Accept a public display label. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "run",
|
||||
"signature": "run<Value>(input: Box<Value>): Promise<Present<Value>>",
|
||||
"summary": "Run one typed input.",
|
||||
"jsDoc": "/** Run one typed input. */"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "Box",
|
||||
"declaration": "export interface Box<T> {\n readonly value: T;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Present",
|
||||
"declaration": "export type Present<T> = T extends null | undefined ? never : T;"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */
|
||||
import { z } from 'zod'
|
||||
|
||||
const Payload$schema = z.object({
|
||||
'name': z.string(),
|
||||
'count': z.number().optional(),
|
||||
}).describe('Runtime-validating data root.')
|
||||
|
||||
export const Payload = Payload$schema
|
||||
|
||||
export const TYPERT = {
|
||||
package: '@fixture/host',
|
||||
face: 'host',
|
||||
schemas: [
|
||||
{ name: 'Payload', schema: Payload },
|
||||
],
|
||||
model: {
|
||||
"services": [
|
||||
{
|
||||
"description": "Service exported only through a non-default alias.",
|
||||
"summary": "Service exported only through a non-default alias.",
|
||||
"tags": [],
|
||||
"jsDoc": "/** Service exported only through a non-default alias. */",
|
||||
"key": "aliased",
|
||||
"exportName": "PublicAliasedService",
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "ready",
|
||||
"signature": "ready(): boolean",
|
||||
"summary": "Report readiness.",
|
||||
"jsDoc": "/** Report readiness. */"
|
||||
}
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
{
|
||||
"description": "Service exported only through the package default.",
|
||||
"summary": "Service exported only through the package default.",
|
||||
"tags": [],
|
||||
"jsDoc": "/** Service exported only through the package default. */",
|
||||
"key": "defaultOnly",
|
||||
"exportName": "default",
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "ready",
|
||||
"signature": "ready(): boolean",
|
||||
"summary": "Report readiness.",
|
||||
"jsDoc": "/** Report readiness. */"
|
||||
}
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
{
|
||||
"description": "Fixture service with generic, mapped, and truly external boundary types.",
|
||||
"summary": "Fixture service with generic, mapped, and truly external boundary types.",
|
||||
"tags": [],
|
||||
"jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */",
|
||||
"key": "demo",
|
||||
"exportName": "DemoService",
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "inspect",
|
||||
"signature": "inspect(agent: Agent<{ ready: true }>, flags: Flags<Payload>): Present<Payload>",
|
||||
"summary": "Inspect one agent without flattening its generic state.",
|
||||
"jsDoc": "/** Inspect one agent without flattening its generic state. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "acceptsExternal",
|
||||
"signature": "acceptsExternal(schema: ZodType<string>): void",
|
||||
"summary": "Keep an npm-owned type as External.",
|
||||
"jsDoc": "/** Keep an npm-owned type as External. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "setPhase",
|
||||
"signature": "setPhase(phase: AgentPhase): void",
|
||||
"summary": "Accept a developer-authored enum without flattening it.",
|
||||
"jsDoc": "/** Accept a developer-authored enum without flattening it. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "inspectSyntax",
|
||||
"signature": "inspectSyntax(zoo: SyntaxZoo): void",
|
||||
"summary": "Exercise every retained type-graph shape from a public boundary.",
|
||||
"jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "inspectAsync",
|
||||
"signature": "async inspectAsync(zoo: SyntaxZoo): Promise<void>",
|
||||
"summary": "Preserve async source metadata without changing its type signature.",
|
||||
"jsDoc": "/** Preserve async source metadata without changing its type signature. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "destructure",
|
||||
"signature": "destructure({ name }: Payload, [suffix]: [string]): string",
|
||||
"summary": "Retain an authored binding-pattern parameter.",
|
||||
"jsDoc": "/** Retain an authored binding-pattern parameter. */"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "AbstractEntity",
|
||||
"declaration": "export abstract class AbstractEntity implements Entity {\n abstract readonly id: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Added",
|
||||
"declaration": "export type Added<Value> = { readonly [Key in keyof Value]?: Value[Key] };"
|
||||
},
|
||||
{
|
||||
"name": "Agent",
|
||||
"declaration": "export class Agent<State extends object = { ready: boolean; }> implements Entity {\n readonly id: string;\n state: State;\n get label(): string;\n set label(value: string);\n run<Value>(input: Box<Value>): Promise<Present<Value>>;\n}"
|
||||
},
|
||||
{
|
||||
"name": "AgentPhase",
|
||||
"declaration": "export enum AgentPhase {\n Unknown,\n Idle = 'idle',\n Running = 'running',\n}"
|
||||
},
|
||||
{
|
||||
"name": "Box",
|
||||
"declaration": "export interface Box<T> {\n readonly value: T;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Callable",
|
||||
"declaration": "export interface Callable {\n (value: string): number;\n new (value: string): Entity;\n readonly [key: string]: unknown;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Entity",
|
||||
"declaration": "export interface Entity {\n readonly id: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Flags",
|
||||
"declaration": "export type Flags<T> = { readonly [K in keyof T]?: boolean };"
|
||||
},
|
||||
{
|
||||
"name": "Guards",
|
||||
"declaration": "export interface Guards {\n isEntity(value: unknown): value is Entity;\n isFluent(): this is Guards;\n assertEntity(value: unknown): asserts value is Entity;\n assertPresent(value: unknown): asserts value;\n fluent(): this;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Payload",
|
||||
"declaration": "export interface Payload {\n name: string;\n count?: number;\n}"
|
||||
},
|
||||
{
|
||||
"name": "PlainMap",
|
||||
"declaration": "export type PlainMap<Value> = { [Key in keyof Value]: Value[Key] };"
|
||||
},
|
||||
{
|
||||
"name": "Present",
|
||||
"declaration": "export type Present<T> = T extends null | undefined ? never : T;"
|
||||
},
|
||||
{
|
||||
"name": "Recursive",
|
||||
"declaration": "export interface Recursive extends Box<string> {\n readonly next?: Recursive;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Remapped",
|
||||
"declaration": "export type Remapped<Value> = { -readonly [Key in keyof Value as `get${Capitalize<string & Key>}`]-?: Value[Key] };"
|
||||
},
|
||||
{
|
||||
"name": "Result",
|
||||
"declaration": "export type Result<Value> = Value extends (...arguments_: never[]) => infer Output ? Output : never;"
|
||||
},
|
||||
{
|
||||
"name": "Route",
|
||||
"declaration": "export type Route<From extends string, To extends string> = `/${From}/to/${To}/end`;"
|
||||
},
|
||||
{
|
||||
"name": "StringResult",
|
||||
"declaration": "export type StringResult<Value> = Value extends readonly [infer Output extends string] ? Output : never;"
|
||||
},
|
||||
{
|
||||
"name": "SyntaxZoo",
|
||||
"declaration": "export interface SyntaxZoo {\n anyValue: any;\n bigintValue: bigint;\n parenthesized: (Entity | null);\n literals: 1 | 1n | -2 | -2n | false | `fixed`;\n readonly uniqueToken: unique symbol;\n intersection: Entity & { active: boolean; };\n array: string[];\n tuple: [head: string, count?: number, ...tail: boolean[]];\n unnamedTuple: [string?, ...number[]];\n readonlyTuple: readonly [string, number];\n object: { readonly value?: string; 'quoted-name': number; 1: boolean; ['computed']: symbol; invoke?(input: number): void; };\n callback: <Value extends Entity = Entity>(this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise<Value>;\n constCallback: <const Value extends readonly string[]>(value: Value) => Value;\n factory: new <Value extends Entity>(value: Value) => Value;\n abstractFactory: abstract new (id: string) => AbstractEntity;\n indexed: Payload['name'];\n inferred: Result<() => string>;\n constrainedInfer: StringResult<['value']>;\n topic: Topic<'ready'>;\n route: Route<'source', 'target'>;\n query: typeof phaseOrder;\n instantiatedQuery: typeof genericFactory<string>;\n imported: import('zod').ZodType<string>;\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType<string>;\n importedModule: typeof import('zod');\n process: NodeJS.Process;\n callable: Callable;\n guards: Guards;\n variance: Variance<Entity, Payload, Box<string>>;\n plainMap: PlainMap<Payload>;\n remapped: Remapped<Payload>;\n added: Added<Payload>;\n abstractEntity: AbstractEntity;\n recursive: Recursive;\n tagOnly: TagOnly;\n unpunctuated: Unpunctuated;\n}"
|
||||
},
|
||||
{
|
||||
"name": "TagOnly",
|
||||
"declaration": "export interface TagOnly {\n readonly value: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Topic",
|
||||
"declaration": "export type Topic<Name extends string> = `demo/${Name}`;"
|
||||
},
|
||||
{
|
||||
"name": "Unpunctuated",
|
||||
"declaration": "export interface Unpunctuated {\n readonly value: string;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Variance",
|
||||
"declaration": "export interface Variance<in Input, out Output, in out State> {\n consume: (input: Input) => void;\n readonly produce: () => Output;\n state: State;\n}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tags": [],
|
||||
"name": "demo/property",
|
||||
"signature": "'demo/property'(payload: Payload): void"
|
||||
},
|
||||
{
|
||||
"description": "A generic fixture event.",
|
||||
"summary": "A generic fixture event.",
|
||||
"tags": [
|
||||
{
|
||||
"name": "param",
|
||||
"argument": "agent",
|
||||
"comment": "- emitting agent.",
|
||||
"text": "@param agent - emitting agent.\n *"
|
||||
},
|
||||
{
|
||||
"name": "param",
|
||||
"argument": "payload",
|
||||
"comment": "- event payload.",
|
||||
"text": "@param payload - event payload.\n *"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"comment": "emit",
|
||||
"text": "@mode emit"
|
||||
}
|
||||
],
|
||||
"jsDoc": "/**\n * A generic fixture event.\n * @param agent - emitting agent.\n * @param payload - event payload.\n * @mode emit\n */",
|
||||
"name": "demo/ready",
|
||||
"mode": "emit",
|
||||
"signature": "'demo/ready'(agent: Agent<{ ready: true; }>, payload: Box<Payload>): void"
|
||||
},
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "mode",
|
||||
"comment": "serial",
|
||||
"text": "@mode serial"
|
||||
}
|
||||
],
|
||||
"jsDoc": "/** @mode serial */",
|
||||
"name": "demo/serial-property",
|
||||
"mode": "serial",
|
||||
"signature": "'demo/serial-property'(payload: Payload): void"
|
||||
},
|
||||
{
|
||||
"tags": [],
|
||||
"name": "demo/unmodeled",
|
||||
"signature": "'demo/unmodeled'(): void"
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"description": "Reference-passed capability object.",
|
||||
"summary": "Reference-passed capability object.",
|
||||
"tags": [
|
||||
{
|
||||
"name": "typert",
|
||||
"comment": "object",
|
||||
"text": "@typert object"
|
||||
}
|
||||
],
|
||||
"jsDoc": "/**\n * Reference-passed capability object.\n * @typert object\n */",
|
||||
"name": "Agent",
|
||||
"exportName": "Agent",
|
||||
"members": [
|
||||
{
|
||||
"kind": "property",
|
||||
"name": "id",
|
||||
"signature": "readonly id: string"
|
||||
},
|
||||
{
|
||||
"kind": "property",
|
||||
"name": "state",
|
||||
"signature": "state: State"
|
||||
},
|
||||
{
|
||||
"kind": "getter",
|
||||
"name": "label",
|
||||
"signature": "get label(): string",
|
||||
"summary": "Read the public display label.",
|
||||
"jsDoc": "/** Read the public display label. */"
|
||||
},
|
||||
{
|
||||
"kind": "setter",
|
||||
"name": "label",
|
||||
"signature": "set label(value: string)",
|
||||
"summary": "Accept a public display label.",
|
||||
"jsDoc": "/** Accept a public display label. */"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "run",
|
||||
"signature": "run<Value>(input: Box<Value>): Promise<Present<Value>>",
|
||||
"summary": "Run one typed input.",
|
||||
"jsDoc": "/** Run one typed input. */"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"name": "Box",
|
||||
"declaration": "export interface Box<T> {\n readonly value: T;\n}"
|
||||
},
|
||||
{
|
||||
"name": "Present",
|
||||
"declaration": "export type Present<T> = T extends null | undefined ? never : T;"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -44,7 +44,7 @@ async function mounted(options: {
|
||||
}
|
||||
|
||||
function freshSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
return Session.create(SessionId(id))
|
||||
}
|
||||
|
||||
async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> {
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
* the opened turn satisfies request()'s enclosure precondition.
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
const session = Session.create(SessionId(id))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { id, session } as unknown as Agent
|
||||
return { agent, session }
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('approval invariants', () => {
|
||||
|
||||
it('adopts a bare session first observed through publication', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('bare-approval-session'))
|
||||
const session = Session.create(SessionId('bare-approval-session'))
|
||||
const id = ApprovalRequestId('bare-ask')
|
||||
const asked = {
|
||||
type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' },
|
||||
|
||||
Reference in New Issue
Block a user