diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index 501c117ca4..72d6e09769 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -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', { diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index 236c2df6a4..2daed2c0f6 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -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, diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 781876a9d7..adf4e0b1b3 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -82,7 +82,7 @@ async function stopServer(server: Server): Promise { /** 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.' }], diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fa11079ef7..d9771d6f44 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f5f1dc3aed..49cb965277 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d7ea0feb05..aad6ec34e5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n 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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n 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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 0a20c5b696..71af9534e4 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -67,7 +67,7 @@ async function harness(): Promise { 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', diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 1ebfac01db..bbd9bcfcb1 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -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', diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 9a4f3d2221..fddbaceb80 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -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('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - 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, }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index b3dc707b27..132135bd48 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -185,7 +185,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } 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', { diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 51ad8c4991..d2dcda461e 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -169,7 +169,7 @@ function deferred(): { promise: Promise; 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 diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index bd347e6d1e..9eadf57a66 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -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) }) diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 5a0ae42c98..f063609808 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -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' }], diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 9342fa193e..c5a68c9de6 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -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, diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index f9f4d661db..73d31bec84 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -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) diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index f5233c70de..b9e5e55241 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -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 () => { diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 35beb67406..59ffa66a89 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -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/) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index c75815508a..f6be6e1e67 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -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[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:') diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 36eba5f519..5de9ea8f5e 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -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) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index be9e512f92..661767f4e4 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -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'), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b814036d4f..84cf173171 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAvailability', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 9ec23752fc..530fbc0a3c 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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 { + 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. diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index c05c526f2c..ad3a3ef507 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -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: '{}', }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 11bcbd233b..565bb73f63 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -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 diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index e5b423c688..ddd6d9dc2d 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index f27985bd63..8edcc74b7c 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -17,7 +17,7 @@ import type { function stubAgent(rawId: string, overrides: Partial = {}): 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 { 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"') diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index f1a82eeb58..2bec5e3d3d 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -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 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index bad0cc33d6..0fde9a4657 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index a28264c355..95cc4129e4 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -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` 配置只控制写入。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5aaa5337bd..132cf66844 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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 + const record = input as Record 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(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(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() /** * 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) } /** diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index f44ca3be02..f93c898375 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -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() diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 9fd072c325..0e7a0629c3 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -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')) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 2cae9b3c4b..32af9bbe48 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -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) })) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 2231730190..5a24618fca 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -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) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e776a13738..7258726ad1 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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() @@ -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: 'Additional instructions from: pkg/AGENTS.md' }], 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 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) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 6ce7313f4a..477720f991 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -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).sourceEventSeqs).toBeUndefined() expect((s.events[0] as SessionEvent).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' }, diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index b06e29866d..7b037d266a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -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' }) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 58d9ba2e1d..c3e7271a0e 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -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: {}, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index ca2db709f5..dea824a39e 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -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 { - 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, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index d0583092e0..c87b136db7 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -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(), diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index f978da2645..70ecd2ce5a 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -9,7 +9,7 @@ function output(over: Partial = {}): 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' }) }) diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index 4efaae36c6..f1792be67f 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -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, diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 697d14e1ee..7009cc6a33 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -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' }, diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index cb2dc2eadd..7ebee4cdb7 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -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, { diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index cef086a502..87a295e90c 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -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 { // 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) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 5fee92ffe7..b3478e0e32 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -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, diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 14200567ce..0e890bc8bc 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -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, diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 2462b00c5d..e677325289 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -21,7 +21,7 @@ const ptyServiceDisposers = new WeakMap Promise>() 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: {}, diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index 5162a587a1..fa3cb6727e 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -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: {}, diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index adf4f36fd6..81a60be733 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -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, diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index f006b8442f..7a99983a42 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -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, diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 063cfadfa3..96fc2e1030 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -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, diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 463d5b3963..c535847f58 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -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) diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index ca9384b616..5361dd5b45 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -255,7 +255,7 @@ export function runPersistenceContract(name: string, make: () => Promise message.content.some(block => block.type === 'tool-result')) expect(resumedResult?.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('call-risk'), isError: true, diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index fc86d0ef1b..411df34d8d 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -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' }], diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 809971b798..7878026345 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -143,7 +143,7 @@ export abstract class SessionQueryService extends Service { */ async readSession(sessionId: SessionId): Promise { 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), diff --git a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts index 5390607d23..3c6ef6ed0b 100644 --- a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts @@ -31,7 +31,7 @@ async function settle(): Promise { 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' }, diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts index 554cfab8ca..79a80745e3 100644 --- a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts @@ -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/) diff --git a/packages/session-title/session-title/tests/rename.spec.ts b/packages/session-title/session-title/tests/rename.spec.ts index c7069c5c2f..ff38900f47 100644 --- a/packages/session-title/session-title/tests/rename.spec.ts +++ b/packages/session-title/session-title/tests/rename.spec.ts @@ -61,7 +61,7 @@ describe('SessionTitleService.rename', () => { const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).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/) }) diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 632b7ec749..6a425ae36f 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -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, }) diff --git a/packages/session-title/session-title/tests/session-title.spec.ts b/packages/session-title/session-title/tests/session-title.spec.ts index 8b2a24a314..c6c3c4b9d3 100644 --- a/packages/session-title/session-title/tests/session-title.spec.ts +++ b/packages/session-title/session-title/tests/session-title.spec.ts @@ -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], diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 681885bf33..eabccb9b5c 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -38,7 +38,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { 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) diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index 836b40009d..a6b5dcf3e1 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -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] } diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 55e611f202..4f6e6e2ce8 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -18,7 +18,7 @@ const agentScopeDisposers = new WeakMap Promise>() 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: {}, diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 2883cfdc02..154fde4857 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -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 } } diff --git a/packages/typert/generator/tests/.generated-model-O7FJNT/host.mjs b/packages/typert/generator/tests/.generated-model-O7FJNT/host.mjs new file mode 100644 index 0000000000..f70155b311 --- /dev/null +++ b/packages/typert/generator/tests/.generated-model-O7FJNT/host.mjs @@ -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): Present", + "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): 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", + "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 = { readonly [Key in keyof Value]?: Value[Key] };" + }, + { + "name": "Agent", + "declaration": "export class Agent implements Entity {\n readonly id: string;\n state: State;\n get label(): string;\n set label(value: string);\n run(input: Box): Promise>;\n}" + }, + { + "name": "AgentPhase", + "declaration": "export enum AgentPhase {\n Unknown,\n Idle = 'idle',\n Running = 'running',\n}" + }, + { + "name": "Box", + "declaration": "export interface Box {\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 = { 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 = { [Key in keyof Value]: Value[Key] };" + }, + { + "name": "Present", + "declaration": "export type Present = T extends null | undefined ? never : T;" + }, + { + "name": "Recursive", + "declaration": "export interface Recursive extends Box {\n readonly next?: Recursive;\n}" + }, + { + "name": "Remapped", + "declaration": "export type Remapped = { -readonly [Key in keyof Value as `get${Capitalize}`]-?: Value[Key] };" + }, + { + "name": "Result", + "declaration": "export type Result = Value extends (...arguments_: never[]) => infer Output ? Output : never;" + }, + { + "name": "Route", + "declaration": "export type Route = `/${From}/to/${To}/end`;" + }, + { + "name": "StringResult", + "declaration": "export type StringResult = 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: (this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise;\n constCallback: (value: Value) => Value;\n factory: new (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;\n imported: import('zod').ZodType;\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType;\n importedModule: typeof import('zod');\n process: NodeJS.Process;\n callable: Callable;\n guards: Guards;\n variance: Variance>;\n plainMap: PlainMap;\n remapped: Remapped;\n added: Added;\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 = `demo/${Name}`;" + }, + { + "name": "Unpunctuated", + "declaration": "export interface Unpunctuated {\n readonly value: string;\n}" + }, + { + "name": "Variance", + "declaration": "export interface Variance {\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): 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(input: Box): Promise>", + "summary": "Run one typed input.", + "jsDoc": "/** Run one typed input. */" + } + ], + "types": [ + { + "name": "Box", + "declaration": "export interface Box {\n readonly value: T;\n}" + }, + { + "name": "Present", + "declaration": "export type Present = T extends null | undefined ? never : T;" + } + ] + } + ] + }, +} diff --git a/packages/typert/generator/tests/.generated-model-qwn8sk/host.mjs b/packages/typert/generator/tests/.generated-model-qwn8sk/host.mjs new file mode 100644 index 0000000000..f70155b311 --- /dev/null +++ b/packages/typert/generator/tests/.generated-model-qwn8sk/host.mjs @@ -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): Present", + "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): 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", + "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 = { readonly [Key in keyof Value]?: Value[Key] };" + }, + { + "name": "Agent", + "declaration": "export class Agent implements Entity {\n readonly id: string;\n state: State;\n get label(): string;\n set label(value: string);\n run(input: Box): Promise>;\n}" + }, + { + "name": "AgentPhase", + "declaration": "export enum AgentPhase {\n Unknown,\n Idle = 'idle',\n Running = 'running',\n}" + }, + { + "name": "Box", + "declaration": "export interface Box {\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 = { 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 = { [Key in keyof Value]: Value[Key] };" + }, + { + "name": "Present", + "declaration": "export type Present = T extends null | undefined ? never : T;" + }, + { + "name": "Recursive", + "declaration": "export interface Recursive extends Box {\n readonly next?: Recursive;\n}" + }, + { + "name": "Remapped", + "declaration": "export type Remapped = { -readonly [Key in keyof Value as `get${Capitalize}`]-?: Value[Key] };" + }, + { + "name": "Result", + "declaration": "export type Result = Value extends (...arguments_: never[]) => infer Output ? Output : never;" + }, + { + "name": "Route", + "declaration": "export type Route = `/${From}/to/${To}/end`;" + }, + { + "name": "StringResult", + "declaration": "export type StringResult = 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: (this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise;\n constCallback: (value: Value) => Value;\n factory: new (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;\n imported: import('zod').ZodType;\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType;\n importedModule: typeof import('zod');\n process: NodeJS.Process;\n callable: Callable;\n guards: Guards;\n variance: Variance>;\n plainMap: PlainMap;\n remapped: Remapped;\n added: Added;\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 = `demo/${Name}`;" + }, + { + "name": "Unpunctuated", + "declaration": "export interface Unpunctuated {\n readonly value: string;\n}" + }, + { + "name": "Variance", + "declaration": "export interface Variance {\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): 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(input: Box): Promise>", + "summary": "Run one typed input.", + "jsDoc": "/** Run one typed input. */" + } + ], + "types": [ + { + "name": "Box", + "declaration": "export interface Box {\n readonly value: T;\n}" + }, + { + "name": "Present", + "declaration": "export type Present = T extends null | undefined ? never : T;" + } + ] + } + ] + }, +} diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index d161157389..4940e3b155 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -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 { diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 73982d3c34..2bb66caa9d 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -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 } diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts index 6be086df4a..bf23b4106d 100644 --- a/packages/ui/user-approval/tests/invariant.spec.ts +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -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' },