From 20773e12bd8f9757b0a99a4fc7b0b8103f6a8d4e Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:16:15 +0800 Subject: [PATCH 01/40] docs: add ADR TSC-first Build and One TSConfig --- docs/rfc/README.md | 1 + .../implemented/2026-06-20-ts-build-config.md | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 docs/rfc/implemented/2026-06-20-ts-build-config.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 48e99d37b8..88406034d5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -60,6 +60,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | | [ACP snapshot tests — record-once / replay-deterministic](implemented/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | +| [TSC-first build and one tsconfig](implemented/2026-06-20-ts-build-config.md) | 2026-06-20 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-20-ts-build-config.md b/docs/rfc/implemented/2026-06-20-ts-build-config.md new file mode 100644 index 0000000000..ab56b07e83 --- /dev/null +++ b/docs/rfc/implemented/2026-06-20-ts-build-config.md @@ -0,0 +1,67 @@ +# RFC: TSC-first build and one tsconfig + +Status: implemented (accepted 2026-06-20) + + + +## Context + +The current TypeScript build and typecheck setup had these issues: + +- `build` used `tsc` to transform `.ts` to `.d.ts` files for `packages/*` and `vendor/*`, and then used `tsdown` to transform `.ts` to bundled `.js` files. This made two tools do TypeScript transform. +- `typecheck` tended to validate packages, vendor source, examples, tests, and scripts through one root typecheck config. + +The goal is to make build and typecheck use matching tsconfig boundaries and TypeScript resolution/transform behavior. Build should generate `.js`, `.d.ts`, `.js.map`, and `.d.ts.map` through one compiler and config, so publish output and type validation stay consistent. + +Validation found several concrete technical issues and possible routes: + +- `tsdown` uses `oxc` to transform TypeScript, which is not the same behavior as `tsc`. + - Bundled `.d.ts` emitted by `tsdown` conflicts with Cordis' internal relative module augmentation shape. + - The tsc output is affected by `allowImportingTsExtensions`, so we need to ensure that generated `.js` files do not import `.ts` files and generated `.d.ts` files do not import `.js` files. Therefore, we need to adjust the import specifiers to extensionless in the TypeScript source. + - Bundled `.js` emitted by `tsdown` is not the same behavior as per-file `.js` emitted by `tsc -b`, such as decorator transform behavior. +- `vendor/*/src`, examples, tests, and scripts cannot all be plain-included in one root strict program. + - Directly typechecking `vendor/*/src` under the root strict config triggers many type errors outside this project's ownership. + - `package/*` dependencies on `vendor` are resolved to the `vendor/*/lib` for different tsconfig strictness. + + +## Decision + +In-package relative imports are extensionless. + +`pnpm run build` is a two-stage build: + +- Stage 1: `tsc -b tsconfig.build.json` emits publishable per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/typings`. This is the authoritative TypeScript compilation result. For publish we should keep `.d.ts` and ignore `.js` / `.js.map` / `.d.ts.map` + - The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results. +- Stage 2: a bundler reads the emitted JS under `lib/typings` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. + +`tsdown` is no longer the owner of TypeScript compilation or declaration output. + +`pnpm run typecheck` runs build mode over the root `tsconfig.json`. +- The root `tsconfig.json` is the single development/typecheck project. It has `noEmit` for demos, examples, tests, and scripts, and validates package/vendor source through references. +- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/typings` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/tsconfig.json` or `vendor/*/tsconfig.json`. + +The command orchestration shape is: + +```sh +pnpm run build: +tsc -b tsconfig.build.json +tsdown + +pnpm run typecheck: +tsc -b tsconfig.json +``` + +`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. + +## Consequences + +Build responsibilities are clearer: + +- Each module under `packages/*` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. +- The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. + - `lib/typings/*.d.ts` is the publish declaration output. + - `lib/typings/*.js` is only a bundler input and must not be used as a runtime entry or public import target. + - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. +- The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. + +The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. From 6f6e0517d452c5bce3cc06e5c7f7387e56cf43dc Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:17:38 +0800 Subject: [PATCH 02/40] refactor: ts in packages use extensionless import --- packages/acp/src/index.ts | 2 +- packages/agent-loop/src/agent.ts | 4 ++-- packages/agent-loop/src/index.ts | 8 ++++---- packages/agent-loop/src/loop.ts | 2 +- packages/agent/src/index.ts | 4 ++-- packages/bash-local/src/index.ts | 8 ++++---- packages/bash/src/index.ts | 4 ++-- packages/llm-deepseek/src/adapter.ts | 10 +++++----- packages/llm-deepseek/src/index.ts | 16 ++++++++-------- packages/llm-deepseek/src/serialize.ts | 2 +- packages/llm-deepseek/src/translate.ts | 4 ++-- packages/llm-pi-ai/src/adapter.ts | 2 +- packages/llm-pi-ai/src/index.ts | 10 +++++----- packages/llm/src/assembler.ts | 6 +++--- packages/llm/src/index.ts | 16 ++++++++-------- packages/llm/src/types.ts | 2 +- packages/session-persistence-jsonl/src/index.ts | 2 +- packages/session-persistence-sqlite/src/index.ts | 4 ++-- packages/session/src/index.ts | 12 ++++++------ packages/session/src/repair.ts | 2 +- packages/tools/src/index.ts | 2 +- packages/tools/src/schema.ts | 2 +- 22 files changed, 62 insertions(+), 62 deletions(-) diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 780e907559..88441fb9c3 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -69,7 +69,7 @@ import { harnessBlockToAcpContent, promptHasUnsupportedContent, turnEndToStopReason, -} from './codec.ts' +} from './codec' export const name = 'acp' // The bridge programs against the interface packages only (architecture rule: diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts index e964685b86..c207867d74 100644 --- a/packages/agent-loop/src/agent.ts +++ b/packages/agent-loop/src/agent.ts @@ -11,8 +11,8 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' -import { Inbox } from './inbox.ts' -import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' +import { Inbox } from './inbox' +import { isTurnOpen, lastTurnNumber, runLoop } from './loop' /** * The concrete {@link Agent} implementation owned by the agent-loop plugin. diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index f118959fce..7fadf00d24 100644 --- a/packages/agent-loop/src/index.ts +++ b/packages/agent-loop/src/index.ts @@ -18,11 +18,11 @@ import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { ReactLoopAgent } from './agent.ts' +import { ReactLoopAgent } from './agent' -export { ReactLoopAgent } from './agent.ts' -export { Inbox, type InboxMessage } from './inbox.ts' -export { runLoop } from './loop.ts' +export { ReactLoopAgent } from './agent' +export { Inbox, type InboxMessage } from './inbox' +export { runLoop } from './loop' declare module 'cordis' { interface Context { diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index dc1cbcf278..9063acca92 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -13,7 +13,7 @@ import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { ReactLoopAgent } from './agent.ts' +import type { ReactLoopAgent } from './agent' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ type CodedError = Error & { code?: string } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c9181081a3..beac6807f9 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -7,9 +7,9 @@ import { Context, Service } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './types' -export * from './types.ts' +export * from './types' declare module 'cordis' { interface Context { diff --git a/packages/bash-local/src/index.ts b/packages/bash-local/src/index.ts index 7320276a1a..bf8f448b53 100644 --- a/packages/bash-local/src/index.ts +++ b/packages/bash-local/src/index.ts @@ -17,11 +17,11 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' -import { runBash } from './run.ts' -import type { RunInternals, RunningBash } from './run.ts' +import { runBash } from './run' +import type { RunInternals, RunningBash } from './run' -export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' -export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts' +export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run' +export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run' /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { diff --git a/packages/bash/src/index.ts b/packages/bash/src/index.ts index e22aad5ff3..af8b1a6727 100644 --- a/packages/bash/src/index.ts +++ b/packages/bash/src/index.ts @@ -15,7 +15,7 @@ */ import { Context, Service } from 'cordis' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types' export type { BashExecRequest, @@ -26,7 +26,7 @@ export type { BashTaskRead, BashTaskStatus, CollectedOutput, -} from './types.ts' +} from './types' declare module 'cordis' { interface Context { diff --git a/packages/llm-deepseek/src/adapter.ts b/packages/llm-deepseek/src/adapter.ts index fda527359a..f9250987f3 100644 --- a/packages/llm-deepseek/src/adapter.ts +++ b/packages/llm-deepseek/src/adapter.ts @@ -7,11 +7,11 @@ import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { serializeRequest } from './serialize.ts' -import type { RequestDefaults } from './serialize.ts' -import { parseSse } from './sse.ts' -import { translate } from './translate.ts' -import type { WireError } from './types.ts' +import { serializeRequest } from './serialize' +import type { RequestDefaults } from './serialize' +import { parseSse } from './sse' +import { translate } from './translate' +import type { WireError } from './types' export interface DeepSeekAdapterOptions { apiKey: string diff --git a/packages/llm-deepseek/src/index.ts b/packages/llm-deepseek/src/index.ts index 79313f910f..f4f7e43635 100644 --- a/packages/llm-deepseek/src/index.ts +++ b/packages/llm-deepseek/src/index.ts @@ -21,15 +21,15 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' -import { DeepSeekAdapter } from './adapter.ts' +import { DeepSeekAdapter } from './adapter' -export { DeepSeekAdapter, httpErrorCode } from './adapter.ts' -export type { DeepSeekAdapterOptions } from './adapter.ts' -export { serializeMessages, serializeRequest } from './serialize.ts' -export type { RequestDefaults } from './serialize.ts' -export { DONE, parseSse } from './sse.ts' -export { mapFinishReason, mapUsage, translate } from './translate.ts' -export type * from './types.ts' +export { DeepSeekAdapter, httpErrorCode } from './adapter' +export type { DeepSeekAdapterOptions } from './adapter' +export { serializeMessages, serializeRequest } from './serialize' +export type { RequestDefaults } from './serialize' +export { DONE, parseSse } from './sse' +export { mapFinishReason, mapUsage, translate } from './translate' +export type * from './types' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm-deepseek/src/serialize.ts b/packages/llm-deepseek/src/serialize.ts index 4e967d6667..11b9028af0 100644 --- a/packages/llm-deepseek/src/serialize.ts +++ b/packages/llm-deepseek/src/serialize.ts @@ -18,7 +18,7 @@ import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { WireMessage, WireRequest, WireTool } from './types.ts' +import type { WireMessage, WireRequest, WireTool } from './types' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { diff --git a/packages/llm-deepseek/src/translate.ts b/packages/llm-deepseek/src/translate.ts index 08cc019b61..ea5e50d7c1 100644 --- a/packages/llm-deepseek/src/translate.ts +++ b/packages/llm-deepseek/src/translate.ts @@ -16,8 +16,8 @@ import { CallId, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' -import { DONE } from './sse.ts' -import type { WireChunk, WireUsage } from './types.ts' +import { DONE } from './sse' +import type { WireChunk, WireUsage } from './types' /** One open block under assembly. */ interface OpenBlock { diff --git a/packages/llm-pi-ai/src/adapter.ts b/packages/llm-pi-ai/src/adapter.ts index 38b05dc007..28046d0e04 100644 --- a/packages/llm-pi-ai/src/adapter.ts +++ b/packages/llm-pi-ai/src/adapter.ts @@ -15,7 +15,7 @@ import { stream as piStream } from '@earendil-works/pi-ai' import type { Model } from '@earendil-works/pi-ai' import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' -import { toPiContext, toStreamChunks } from './convert.ts' +import { toPiContext, toStreamChunks } from './convert' /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */ export type PiAiReasoning = 'off' | 'high' | 'xhigh' diff --git a/packages/llm-pi-ai/src/index.ts b/packages/llm-pi-ai/src/index.ts index bef0d4b3f5..d146df5824 100644 --- a/packages/llm-pi-ai/src/index.ts +++ b/packages/llm-pi-ai/src/index.ts @@ -19,12 +19,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' -import { PiAiAdapter } from './adapter.ts' -import type { PiAiReasoning } from './adapter.ts' +import { PiAiAdapter } from './adapter' +import type { PiAiReasoning } from './adapter' -export { buildModel, PiAiAdapter } from './adapter.ts' -export type { PiAiAdapterOptions, PiAiReasoning } from './adapter.ts' -export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert.ts' +export { buildModel, PiAiAdapter } from './adapter' +export type { PiAiAdapterOptions, PiAiReasoning } from './adapter' +export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert' export const name = 'llm-pi-ai' export const inject = ['llm'] diff --git a/packages/llm/src/assembler.ts b/packages/llm/src/assembler.ts index a61d6cf044..9a8ea01b63 100644 --- a/packages/llm/src/assembler.ts +++ b/packages/llm/src/assembler.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-llm/assembler */ -import { CallId } from './brand.ts' -import { assertNever } from './never.ts' -import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts' +import { CallId } from './brand' +import { assertNever } from './never' +import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types' interface PartialBlock { blockType: string diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 460316ea50..b7348fa46d 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -7,15 +7,15 @@ */ import { Context, Service } from 'cordis' -import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts' -import { BlockAssembler } from './assembler.ts' -import { HarnessError } from './error.ts' +import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types' +import { BlockAssembler } from './assembler' +import { HarnessError } from './error' -export * from './brand.ts' -export * from './never.ts' -export * from './error.ts' -export * from './types.ts' -export { BlockAssembler } from './assembler.ts' +export * from './brand' +export * from './never' +export * from './error' +export * from './types' +export { BlockAssembler } from './assembler' declare module 'cordis' { interface Context { diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index 863de94b16..0dd48417d1 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -19,7 +19,7 @@ * ``` */ -import type { CallId } from './brand.ts' +import type { CallId } from './brand' /** Cache hint attached to a content block (provider-interpreted). */ export type CacheHint = 'ephemeral' diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence-jsonl/src/index.ts index 7a0c97637c..faa1e11de7 100644 --- a/packages/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence-jsonl/src/index.ts @@ -32,7 +32,7 @@ import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, sidecarPath, toHeaderLine, -} from './format.ts' +} from './format' export interface Config { /** diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence-sqlite/src/index.ts index d28fc8d6f7..1eab7df5c0 100644 --- a/packages/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence-sqlite/src/index.ts @@ -31,9 +31,9 @@ import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' import { openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, -} from './schema.ts' +} from './schema' -export { SCHEMA_VERSION } from './schema.ts' +export { SCHEMA_VERSION } from './schema' /** Plugin configuration. */ export interface Config { diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 4796c05f51..f8d2993c98 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -9,13 +9,13 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import { SessionId } from './types.ts' -import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts' -import { isJsonValue } from './json.ts' +import { SessionId } from './types' +import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types' +import { isJsonValue } from './json' -export * from './types.ts' -export { isJsonValue } from './json.ts' -export { interruptedTurnClosers } from './repair.ts' +export * from './types' +export { isJsonValue } from './json' +export { interruptedTurnClosers } from './repair' declare module 'cordis' { interface Context { diff --git a/packages/session/src/repair.ts b/packages/session/src/repair.ts index 5cc62b37c7..6215ebc2a8 100644 --- a/packages/session/src/repair.ts +++ b/packages/session/src/repair.ts @@ -36,7 +36,7 @@ */ import type { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from './types.ts' +import type { SessionEvent } from './types' /** * Scan `events` for an open turn/step at the tail and return the synthetic diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index eee77445eb..32bb64160f 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -24,7 +24,7 @@ export { type InferArgs, type DefineToolOptions, type JsonSchemaObject, -} from './schema.ts' +} from './schema' declare module 'cordis' { interface Context { diff --git a/packages/tools/src/schema.ts b/packages/tools/src/schema.ts index 5e8887f11b..b38861fc2d 100644 --- a/packages/tools/src/schema.ts +++ b/packages/tools/src/schema.ts @@ -21,7 +21,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts' +import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index' // --------------------------------------------------------------------------- // SchemaSpec — the author-facing per-property type From 29e674bfea988fcbccd6e847d14791a978ddfd66 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:18:25 +0800 Subject: [PATCH 03/40] refactor: ts in vendor use extensionless import --- vendor/hmr/src/index.ts | 2 +- vendor/loader/src/config/entry.ts | 8 ++++---- vendor/loader/src/config/group.ts | 4 ++-- vendor/loader/src/config/isolate.ts | 4 ++-- vendor/loader/src/config/tree.ts | 4 ++-- vendor/loader/src/index.ts | 20 ++++++++++---------- vendor/logger-console/src/browser.ts | 4 ++-- vendor/logger-console/src/index.ts | 4 ++-- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index ada10cc934..8948625db6 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -4,7 +4,7 @@ import { ModuleJob, ModuleLoader, ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' import { ChokidarOptions, FSWatcher, watch } from 'chokidar' import { relative, resolve } from 'node:path' -import { handleError } from './error.ts' +import { handleError } from './error' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..8acba39548 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,9 +1,9 @@ import { Context, Fiber, Inject } from 'cordis' import { deepEqual, isNullable } from 'cosmokit' -import { Loader } from '../index.ts' -import { EntryGroup } from './group.ts' -import { EntryTree } from './tree.ts' -import { evaluate, interpolate } from './utils.ts' +import { Loader } from '../index' +import { EntryGroup } from './group' +import { EntryTree } from './tree' +import { evaluate, interpolate } from './utils' /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index f6ce0fe306..5966d87eb8 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -1,6 +1,6 @@ import { Context, Service } from 'cordis' -import { Entry, EntryOptions } from './entry.ts' -import { EntryTree } from './tree.ts' +import { Entry, EntryOptions } from './entry' +import { EntryTree } from './tree' /** Runtime owner for a list of child loader entries. */ export class EntryGroup { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index a2e930c4fb..4b2f1df894 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -1,8 +1,8 @@ import { Context } from 'cordis' import { Dict } from 'cosmokit' -import { Entry } from './entry.ts' +import { Entry } from './entry' -declare module './entry.ts' { +declare module './entry' { interface EntryOptions { intercept?: Dict | null isolate?: Dict | null diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 6855884e11..53f71220e1 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -1,7 +1,7 @@ import { composeError, Context } from 'cordis' import { Dict, isNonNullable } from 'cosmokit' -import { Entry, EntryOptions } from './entry.ts' -import { EntryGroup } from './group.ts' +import { Entry, EntryOptions } from './entry' +import { EntryGroup } from './group' /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ export abstract class EntryTree { diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index e18fc2ffa2..764f04f995 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,22 +1,22 @@ import { Context, Inject, Service } from 'cordis' import { defineProperty, Dict, isNullable } from 'cosmokit' -import { ModuleLoader } from './internal.ts' -import { Entry, EntryOptions } from './config/entry.ts' -import isolate from './config/isolate.ts' -import { EntryTree } from './config/tree.ts' +import { ModuleLoader } from './internal' +import { Entry, EntryOptions } from './config/entry' +import isolate from './config/isolate' +import { EntryTree } from './config/tree' /** Re-export entry node APIs. */ -export * from './config/entry.ts' +export * from './config/entry' /** Re-export nested entry group APIs. */ -export * from './config/group.ts' +export * from './config/group' /** Re-export service isolation helpers. */ -export * from './config/isolate.ts' +export * from './config/isolate' /** Re-export entry tree persistence APIs. */ -export * from './config/tree.ts' +export * from './config/tree' /** Re-export loader config expression helpers. */ -export * from './config/utils.ts' +export * from './config/utils' /** Re-export Node internal module loader compatibility types. */ -export * from './internal.ts' +export * from './internal' declare module 'cordis' { interface Events { diff --git a/vendor/logger-console/src/browser.ts b/vendor/logger-console/src/browser.ts index bdbeaaf226..fb35366d14 100644 --- a/vendor/logger-console/src/browser.ts +++ b/vendor/logger-console/src/browser.ts @@ -1,8 +1,8 @@ import { Message } from 'cordis' -import { ConsoleExporter as Base } from './shared.js' +import { ConsoleExporter as Base } from './shared' /** Re-export shared console exporter config and base implementation. */ -export * from './shared.js' +export * from './shared' /** Browser console exporter that dispatches to native console methods. */ export class ConsoleExporter extends Base { diff --git a/vendor/logger-console/src/index.ts b/vendor/logger-console/src/index.ts index 87ab53d6dc..905287b1e8 100644 --- a/vendor/logger-console/src/index.ts +++ b/vendor/logger-console/src/index.ts @@ -1,10 +1,10 @@ import { Formatter } from 'cordis' import { inspect } from 'node:util' import supportsColor from 'supports-color' -import { ConsoleExporter as Base } from './shared.js' +import { ConsoleExporter as Base } from './shared' /** Re-export shared console exporter config and base implementation. */ -export * from './shared.js' +export * from './shared' const inspectFormatter: Formatter = (value, target) => { return inspect(value, { colors: !!target.colors, depth: Infinity, compact: true, breakLength: Infinity }) From 6b15b606d7c848ba14f5b678712a98adb7a2db12 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:20:04 +0800 Subject: [PATCH 04/40] refactor: packages/tsconfig.json in packages use lib/typings/ as output subfolder --- packages/acp/package.json | 7 ++++--- packages/acp/tsconfig.json | 2 +- packages/agent-loop/package.json | 7 ++++--- packages/agent-loop/tsconfig.json | 2 +- packages/agent/package.json | 7 ++++--- packages/agent/tsconfig.json | 2 +- packages/bash-local/package.json | 7 ++++--- packages/bash-local/tsconfig.json | 2 +- packages/bash/package.json | 7 ++++--- packages/bash/tsconfig.json | 2 +- packages/invariants/package.json | 7 ++++--- packages/invariants/tsconfig.json | 2 +- packages/llm-deepseek/package.json | 7 ++++--- packages/llm-deepseek/tsconfig.json | 2 +- packages/llm-pi-ai/package.json | 7 ++++--- packages/llm-pi-ai/tsconfig.json | 2 +- packages/llm-replay/package.json | 7 ++++--- packages/llm-replay/tsconfig.json | 2 +- packages/llm/package.json | 7 ++++--- packages/llm/tsconfig.json | 2 +- packages/session-persistence-jsonl/package.json | 7 ++++--- packages/session-persistence-jsonl/tsconfig.json | 2 +- packages/session-persistence-sqlite/package.json | 7 ++++--- packages/session-persistence-sqlite/tsconfig.json | 2 +- packages/session-persistence/package.json | 7 ++++--- packages/session-persistence/tsconfig.json | 2 +- packages/session/package.json | 7 ++++--- packages/session/tsconfig.json | 2 +- packages/system-prompt/package.json | 7 ++++--- packages/system-prompt/tsconfig.json | 2 +- packages/tool-bash/package.json | 7 ++++--- packages/tool-bash/tsconfig.json | 2 +- packages/tools/package.json | 7 ++++--- packages/tools/tsconfig.json | 2 +- packages/ui-stdio/package.json | 7 ++++--- packages/ui-stdio/tsconfig.json | 2 +- 36 files changed, 90 insertions(+), 72 deletions(-) diff --git a/packages/acp/package.json b/packages/acp/package.json index 0a4a890658..ac23174ade 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/acp/tsconfig.json b/packages/acp/tsconfig.json index 83330256e3..73d850e990 100644 --- a/packages/acp/tsconfig.json +++ b/packages/acp/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/agent-loop/package.json b/packages/agent-loop/package.json index b9744eab2a..9e54e4de4b 100644 --- a/packages/agent-loop/package.json +++ b/packages/agent-loop/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/agent-loop/tsconfig.json b/packages/agent-loop/tsconfig.json index 6751664d5c..93a07b2e41 100644 --- a/packages/agent-loop/tsconfig.json +++ b/packages/agent-loop/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/agent/package.json b/packages/agent/package.json index a215f35fb3..bca0ff7840 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 0806132292..c2b740741a 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/bash-local/package.json b/packages/bash-local/package.json index b786c1bc37..de2f2d6c1a 100644 --- a/packages/bash-local/package.json +++ b/packages/bash-local/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash-local/tsconfig.json b/packages/bash-local/tsconfig.json index a657d8bf8e..576ebe64a8 100644 --- a/packages/bash-local/tsconfig.json +++ b/packages/bash-local/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/bash/package.json b/packages/bash/package.json index 52bf80282f..f65f5a6a7f 100644 --- a/packages/bash/package.json +++ b/packages/bash/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash/tsconfig.json b/packages/bash/tsconfig.json index 2617271c44..f5803cec7f 100644 --- a/packages/bash/tsconfig.json +++ b/packages/bash/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/invariants/package.json b/packages/invariants/package.json index 7508d3e7d8..409596871b 100644 --- a/packages/invariants/package.json +++ b/packages/invariants/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/invariants/tsconfig.json b/packages/invariants/tsconfig.json index 54fbb4adac..e87cca530d 100644 --- a/packages/invariants/tsconfig.json +++ b/packages/invariants/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/llm-deepseek/package.json b/packages/llm-deepseek/package.json index 077a2db169..0517a0c5a9 100644 --- a/packages/llm-deepseek/package.json +++ b/packages/llm-deepseek/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-deepseek/tsconfig.json b/packages/llm-deepseek/tsconfig.json index eea89a4aac..ceacbf1ee2 100644 --- a/packages/llm-deepseek/tsconfig.json +++ b/packages/llm-deepseek/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/llm-pi-ai/package.json b/packages/llm-pi-ai/package.json index 6eb08a4b06..f2e9a34322 100644 --- a/packages/llm-pi-ai/package.json +++ b/packages/llm-pi-ai/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-pi-ai/tsconfig.json b/packages/llm-pi-ai/tsconfig.json index eea89a4aac..ceacbf1ee2 100644 --- a/packages/llm-pi-ai/tsconfig.json +++ b/packages/llm-pi-ai/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/llm-replay/package.json b/packages/llm-replay/package.json index 50d469a352..b25fa04f03 100644 --- a/packages/llm-replay/package.json +++ b/packages/llm-replay/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-replay/tsconfig.json b/packages/llm-replay/tsconfig.json index 0806132292..c2b740741a 100644 --- a/packages/llm-replay/tsconfig.json +++ b/packages/llm-replay/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/llm/package.json b/packages/llm/package.json index 317edc7ac2..835e89af7f 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json index 2617271c44..f5803cec7f 100644 --- a/packages/llm/tsconfig.json +++ b/packages/llm/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/session-persistence-jsonl/package.json b/packages/session-persistence-jsonl/package.json index 6193af910b..6a1e61c361 100644 --- a/packages/session-persistence-jsonl/package.json +++ b/packages/session-persistence-jsonl/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence-jsonl/tsconfig.json b/packages/session-persistence-jsonl/tsconfig.json index 3595f989bd..23465c380e 100644 --- a/packages/session-persistence-jsonl/tsconfig.json +++ b/packages/session-persistence-jsonl/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/session-persistence-sqlite/package.json b/packages/session-persistence-sqlite/package.json index 463cf683be..4d6951ebbd 100644 --- a/packages/session-persistence-sqlite/package.json +++ b/packages/session-persistence-sqlite/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence-sqlite/tsconfig.json b/packages/session-persistence-sqlite/tsconfig.json index 3595f989bd..23465c380e 100644 --- a/packages/session-persistence-sqlite/tsconfig.json +++ b/packages/session-persistence-sqlite/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/session-persistence/package.json b/packages/session-persistence/package.json index bd84fd1826..901381cffc 100644 --- a/packages/session-persistence/package.json +++ b/packages/session-persistence/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence/tsconfig.json b/packages/session-persistence/tsconfig.json index 727294a720..ebfd4b98f3 100644 --- a/packages/session-persistence/tsconfig.json +++ b/packages/session-persistence/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/session/package.json b/packages/session/package.json index f4aa5839bc..d660624853 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json index e226412a53..747dd65daa 100644 --- a/packages/session/tsconfig.json +++ b/packages/session/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json index eed76b8907..b5782f2c38 100644 --- a/packages/system-prompt/package.json +++ b/packages/system-prompt/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json index e226412a53..747dd65daa 100644 --- a/packages/system-prompt/tsconfig.json +++ b/packages/system-prompt/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/tool-bash/package.json b/packages/tool-bash/package.json index aaf4fde4cc..e433fad938 100644 --- a/packages/tool-bash/package.json +++ b/packages/tool-bash/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/tool-bash/tsconfig.json b/packages/tool-bash/tsconfig.json index 4741cb67f3..131f52aca6 100644 --- a/packages/tool-bash/tsconfig.json +++ b/packages/tool-bash/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/tools/package.json b/packages/tools/package.json index a92015e55c..0a578547f2 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json index 8e29228fc8..20d6ab9643 100644 --- a/packages/tools/tsconfig.json +++ b/packages/tools/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ diff --git a/packages/ui-stdio/package.json b/packages/ui-stdio/package.json index 156984a72e..34216be977 100644 --- a/packages/ui-stdio/package.json +++ b/packages/ui-stdio/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui-stdio/tsconfig.json b/packages/ui-stdio/tsconfig.json index 33fa338e5f..f87b686386 100644 --- a/packages/ui-stdio/tsconfig.json +++ b/packages/ui-stdio/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/typings" }, "include": ["src"], "references": [ From 99db5497086bff43aff699c25c6e44e53da9902d Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:21:05 +0800 Subject: [PATCH 05/40] refactor: packages/tsconfig.json in vendor use lib/typings/ as output subfolder --- vendor/cordis/package.json | 7 ++++--- vendor/cordis/tsconfig.json | 2 +- vendor/cosmokit/package.json | 7 ++++--- vendor/cosmokit/tsconfig.json | 2 +- vendor/group/package.json | 7 ++++--- vendor/group/tsconfig.json | 2 +- vendor/hmr/package.json | 7 ++++--- vendor/hmr/tsconfig.json | 2 +- vendor/include/package.json | 7 ++++--- vendor/include/tsconfig.json | 2 +- vendor/loader/package.json | 7 ++++--- vendor/loader/tsconfig.json | 2 +- vendor/logger-console/package.json | 8 +++++--- vendor/logger-console/tsconfig.json | 2 +- vendor/schemastery/package.json | 6 ++++-- vendor/schemastery/tsconfig.json | 2 +- vendor/timer/package.json | 7 ++++--- vendor/timer/tsconfig.json | 2 +- 18 files changed, 46 insertions(+), 35 deletions(-) diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 6b9e59a00b..33bd881dc2 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -6,18 +6,19 @@ "sideEffects": false, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "bin": "bin.js", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "bin.js" ], "author": "Shigma ", diff --git a/vendor/cordis/tsconfig.json b/vendor/cordis/tsconfig.json index b9829bf1df..e0b2a46462 100644 --- a/vendor/cordis/tsconfig.json +++ b/vendor/cordis/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noImplicitAny": false, "noImplicitThis": false, "strictFunctionTypes": false, diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index 92fdf8e903..ccb8f620fd 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/cosmokit/tsconfig.json b/vendor/cosmokit/tsconfig.json index 0db18e0f14..eb79653390 100644 --- a/vendor/cosmokit/tsconfig.json +++ b/vendor/cosmokit/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/group/package.json b/vendor/group/package.json index 86e5043a10..cd638f59a7 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/group/tsconfig.json b/vendor/group/tsconfig.json index 137b02f7ac..2d93e6ae42 100644 --- a/vendor/group/tsconfig.json +++ b/vendor/group/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 075968a3ba..1c3c088dd0 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/hmr/tsconfig.json b/vendor/hmr/tsconfig.json index 033f83429f..cfa1f07afd 100644 --- a/vendor/hmr/tsconfig.json +++ b/vendor/hmr/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/include/package.json b/vendor/include/package.json index d42a1c0739..2b15cb4b90 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/include/tsconfig.json b/vendor/include/tsconfig.json index ae2c70f4bc..056206ecab 100644 --- a/vendor/include/tsconfig.json +++ b/vendor/include/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/loader/package.json b/vendor/loader/package.json index ee5dd088ff..8d43331708 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/loader/tsconfig.json b/vendor/loader/tsconfig.json index 84799662e0..ca6d75810a 100644 --- a/vendor/loader/tsconfig.json +++ b/vendor/loader/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index b4a4c9634e..f96f94b23d 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/shared.d.ts", + "types": "lib/typings/shared.d.ts", "exports": { ".": { - "types": "./lib/shared.d.ts", + "types": "./lib/typings/shared.d.ts", "node": "./lib/index.js", "default": "./lib/browser.js" }, @@ -16,7 +16,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/browser.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/logger-console/tsconfig.json b/vendor/logger-console/tsconfig.json index c632badb1b..8714f410b6 100644 --- a/vendor/logger-console/tsconfig.json +++ b/vendor/logger-console/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 71f7744e5e..42aab72f69 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -5,9 +5,11 @@ "private": true, "main": "lib/index.cjs", "module": "lib/index.mjs", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "files": [ - "lib", + "lib/index.mjs", + "lib/index.cjs", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/schemastery/tsconfig.json b/vendor/schemastery/tsconfig.json index 5797e8902b..f901861a39 100644 --- a/vendor/schemastery/tsconfig.json +++ b/vendor/schemastery/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "module": "preserve", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 30bfe58280..8c7afeafc4 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -5,17 +5,18 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/typings/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typings/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/typings/**/*.d.ts", "src" ], "author": "Shigma ", diff --git a/vendor/timer/tsconfig.json b/vendor/timer/tsconfig.json index 99c40177cd..fc4fc9f4fc 100644 --- a/vendor/timer/tsconfig.json +++ b/vendor/timer/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/typings", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, From 846ea4dd60c9a7f8407547231476a8d162aa3951 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:32:08 +0800 Subject: [PATCH 06/40] docs: vendor README modifications --- vendor/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index 04e63f26c1..87fbc46fb1 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -31,9 +31,10 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added `src` to `files` and a `./src/*` export where missing, removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. -3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json` and declare project references. -4. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/typings/**/*.d.ts`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/typings`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. +3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/typings`, and declare project references. +4. **`loader/src/config/isolate.ts`**: changed the internal declaration merge specifier from `declare module './entry.ts'` to `declare module './entry'` so generated declarations are extensionless and no declaration postprocess is needed. +5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/typings` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. ## Sync procedure From 279e9f17eb62c237f92d404eb6b4a0d262e92868 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:21:42 +0800 Subject: [PATCH 07/40] refactor: ts in packages/*/tests use extensionless import --- packages/acp/tests/bridge.spec.ts | 2 +- packages/acp/tests/codec.spec.ts | 2 +- packages/acp/tests/dispose.spec.ts | 2 +- packages/acp/tests/edges.spec.ts | 2 +- packages/acp/tests/harness.ts | 4 ++-- packages/acp/tests/load.spec.ts | 2 +- packages/acp/tests/multi-session.spec.ts | 2 +- packages/acp/tests/properties.spec.ts | 2 +- packages/acp/tests/stream-update.spec.ts | 2 +- packages/acp/tests/turns.spec.ts | 2 +- packages/agent-loop/tests/agent.spec.ts | 2 +- packages/agent-loop/tests/config-session-id.spec.ts | 2 +- packages/agent-loop/tests/coverage-edges.spec.ts | 2 +- packages/agent-loop/tests/loop.spec.ts | 2 +- packages/agent-loop/tests/resume.spec.ts | 2 +- packages/agent-loop/tests/review-fixes.spec.ts | 2 +- packages/llm-replay/tests/llm-replay.spec.ts | 2 +- packages/session-persistence-jsonl/tests/jsonl.spec.ts | 4 ++-- packages/session-persistence-sqlite/tests/sqlite.spec.ts | 4 ++-- packages/session-persistence/tests/contract.ts | 2 +- packages/session-persistence/tests/persistence.spec.ts | 4 ++-- packages/session/tests/repair.spec.ts | 4 ++-- packages/tool-bash/tests/integration.spec.ts | 2 +- packages/ui-stdio/tests/ui-stdio.spec.ts | 2 +- 24 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/acp/tests/bridge.spec.ts b/packages/acp/tests/bridge.spec.ts index dd10a88bcb..af3b616aff 100644 --- a/packages/acp/tests/bridge.spec.ts +++ b/packages/acp/tests/bridge.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness' /** * End-to-end bridge specs over an in-memory transport: a real diff --git a/packages/acp/tests/codec.spec.ts b/packages/acp/tests/codec.spec.ts index 38a7a6cb41..feb75e9a1a 100644 --- a/packages/acp/tests/codec.spec.ts +++ b/packages/acp/tests/codec.spec.ts @@ -6,7 +6,7 @@ import { harnessBlockToAcpContent, promptHasUnsupportedContent, turnEndToStopReason, -} from '../src/codec.ts' +} from '../src/codec' describe('turnEndToStopReason', () => { // The SDK rejects an unknown stopReason, so this must be total over every diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index 45e351ced5..dc179d9cd2 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { makeBridgeHarness } from './harness.ts' +import { makeBridgeHarness } from './harness' describe('acp bridge — disposal & HMR safety', () => { let storageDir: string diff --git a/packages/acp/tests/edges.spec.ts b/packages/acp/tests/edges.spec.ts index 9484368322..b479b0b120 100644 --- a/packages/acp/tests/edges.spec.ts +++ b/packages/acp/tests/edges.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness' describe('acp bridge — demux & config edges', () => { let storageDir: string diff --git a/packages/acp/tests/harness.ts b/packages/acp/tests/harness.ts index 4f6b5ac17a..4b41b013bd 100644 --- a/packages/acp/tests/harness.ts +++ b/packages/acp/tests/harness.ts @@ -30,8 +30,8 @@ import { type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' -import * as AcpPlugin from '../src/index.ts' -import { type AcpConfig } from '../src/index.ts' +import * as AcpPlugin from '../src/index' +import { type AcpConfig } from '../src/index' /** A scripted mock adapter (mirrors the agent-loop test adapter). */ class MockAdapter extends LlmAdapter { diff --git a/packages/acp/tests/load.spec.ts b/packages/acp/tests/load.spec.ts index f06c707d85..c2a5237d89 100644 --- a/packages/acp/tests/load.spec.ts +++ b/packages/acp/tests/load.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' -import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' +import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness' /** Concatenate the text of all agent_message_chunk updates. */ function messageText(updates: CapturedUpdate[]): string { diff --git a/packages/acp/tests/multi-session.spec.ts b/packages/acp/tests/multi-session.spec.ts index 1c20d2ba39..0a52cb66c1 100644 --- a/packages/acp/tests/multi-session.spec.ts +++ b/packages/acp/tests/multi-session.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' +import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness' /** Text of the agent_message_chunk updates scoped to one session id. */ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string { diff --git a/packages/acp/tests/properties.spec.ts b/packages/acp/tests/properties.spec.ts index 5364c02d7b..4013dae163 100644 --- a/packages/acp/tests/properties.spec.ts +++ b/packages/acp/tests/properties.spec.ts @@ -19,7 +19,7 @@ import fc from 'fast-check' import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' -import { streamSessionEventUpdate } from '../src/index.ts' +import { streamSessionEventUpdate } from '../src/index' const LEGAL_UPDATE_KINDS = new Set([ 'agent_message_chunk', diff --git a/packages/acp/tests/stream-update.spec.ts b/packages/acp/tests/stream-update.spec.ts index cdba5a3bf3..13c4c445cf 100644 --- a/packages/acp/tests/stream-update.spec.ts +++ b/packages/acp/tests/stream-update.spec.ts @@ -3,7 +3,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools' -import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts' +import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index' /** Collect the updates a single event produces (no presenter → generic fallback). */ function updatesFor(event: SessionEvent): SessionNotification['update'][] { diff --git a/packages/acp/tests/turns.spec.ts b/packages/acp/tests/turns.spec.ts index 19c3be2556..da4a38a554 100644 --- a/packages/acp/tests/turns.spec.ts +++ b/packages/acp/tests/turns.spec.ts @@ -11,7 +11,7 @@ import { textResponse, toolCallResponse, type BridgeHarness, -} from './harness.ts' +} from './harness' /** Boilerplate: initialize + create one session, returning its id. */ async function newSession(h: BridgeHarness, clientCapabilities: Record = {}): Promise { diff --git a/packages/agent-loop/tests/agent.spec.ts b/packages/agent-loop/tests/agent.spec.ts index 7c46df956c..1f04fb7de6 100644 --- a/packages/agent-loop/tests/agent.spec.ts +++ b/packages/agent-loop/tests/agent.spec.ts @@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse } from './mock-adapter' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/agent-loop/tests/config-session-id.spec.ts b/packages/agent-loop/tests/config-session-id.spec.ts index 13a62bca8c..2dd911a178 100644 --- a/packages/agent-loop/tests/config-session-id.spec.ts +++ b/packages/agent-loop/tests/config-session-id.spec.ts @@ -10,7 +10,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse } from './mock-adapter' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) diff --git a/packages/agent-loop/tests/coverage-edges.spec.ts b/packages/agent-loop/tests/coverage-edges.spec.ts index 96c061d2dd..ee76a22f6a 100644 --- a/packages/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/agent-loop/tests/coverage-edges.spec.ts @@ -6,7 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/agent-loop/tests/loop.spec.ts index f004e02f91..1d6cdee2d1 100644 --- a/packages/agent-loop/tests/loop.spec.ts +++ b/packages/agent-loop/tests/loop.spec.ts @@ -6,7 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' +import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/agent-loop/tests/resume.spec.ts b/packages/agent-loop/tests/resume.spec.ts index 10313fdd26..508753753b 100644 --- a/packages/agent-loop/tests/resume.spec.ts +++ b/packages/agent-loop/tests/resume.spec.ts @@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse } from './mock-adapter' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/agent-loop/tests/review-fixes.spec.ts index 3f65ae737e..86306f9f9e 100644 --- a/packages/agent-loop/tests/review-fixes.spec.ts +++ b/packages/agent-loop/tests/review-fixes.spec.ts @@ -7,7 +7,7 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter' /** * Regression tests for the findings of the first architecture review diff --git a/packages/llm-replay/tests/llm-replay.spec.ts b/packages/llm-replay/tests/llm-replay.spec.ts index f16e988035..6b8ed2e355 100644 --- a/packages/llm-replay/tests/llm-replay.spec.ts +++ b/packages/llm-replay/tests/llm-replay.spec.ts @@ -14,7 +14,7 @@ import { loadReplayScript, name, parseSessionLog, -} from '../src/index.ts' +} from '../src/index' /** * Unit tests for the replay llm/stream plugin. These drive the listener through diff --git a/packages/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence-jsonl/tests/jsonl.spec.ts index 8257a9853c..88a21b5705 100644 --- a/packages/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence-jsonl/tests/jsonl.spec.ts @@ -6,8 +6,8 @@ import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { encodeSegment, logPath, scanLog, sessionDir, sidecarPath } from '../src/format.ts' -import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { encodeSegment, logPath, scanLog, sessionDir, sidecarPath } from '../src/format' +import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract' let root: string const dirs: string[] = [] diff --git a/packages/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence-sqlite/tests/sqlite.spec.ts index d6216b7b50..3eb7cbad2c 100644 --- a/packages/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence-sqlite/tests/sqlite.spec.ts @@ -6,8 +6,8 @@ import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' -import { openDatabase, scanRows, type EventRow } from '../src/schema.ts' -import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { openDatabase, scanRows, type EventRow } from '../src/schema' +import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) diff --git a/packages/session-persistence/tests/contract.ts b/packages/session-persistence/tests/contract.ts index 73f6f653bd..3be9fadba2 100644 --- a/packages/session-persistence/tests/contract.ts +++ b/packages/session-persistence/tests/contract.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionPersistence } from '../src/index.ts' +import type { SessionPersistence } from '../src/index' /** A backend under test plus its teardown. */ export interface ContractBackend { diff --git a/packages/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/tests/persistence.spec.ts index 5c0a29131f..2c65639060 100644 --- a/packages/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/tests/persistence.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SessionId, isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' -import { SessionPersistence, assertSerializable, seedCoversPrefix } from '../src/index.ts' -import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' +import { SessionPersistence, assertSerializable, seedCoversPrefix } from '../src/index' +import { runPersistenceContract, meta, oneTurnLog } from './contract' /** * A minimal in-memory {@link SessionPersistence} used to (a) cover the abstract diff --git a/packages/session/tests/repair.spec.ts b/packages/session/tests/repair.spec.ts index 57422e7719..2fa1bfae27 100644 --- a/packages/session/tests/repair.spec.ts +++ b/packages/session/tests/repair.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers } from '../src/index.ts' -import type { SessionEvent } from '../src/index.ts' +import { interruptedTurnClosers } from '../src/index' +import type { SessionEvent } from '../src/index' /** * Unit coverage for the crash-recovery closer synthesis. The persistence diff --git a/packages/tool-bash/tests/integration.spec.ts b/packages/tool-bash/tests/integration.spec.ts index d31b3a5a7e..e2687b2708 100644 --- a/packages/tool-bash/tests/integration.spec.ts +++ b/packages/tool-bash/tests/integration.spec.ts @@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter' /** * Full-loop integration: a scripted mock model drives the REAL bash tool diff --git a/packages/ui-stdio/tests/ui-stdio.spec.ts b/packages/ui-stdio/tests/ui-stdio.spec.ts index bd5e0f7f91..72d82b539c 100644 --- a/packages/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/ui-stdio/tests/ui-stdio.spec.ts @@ -5,7 +5,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts' +import { createStdioChat, type Config, type StdioRuntime } from '../src/index' /** * Unit tests for the stdio UI plugin. They drive the REAL plugin body From ec9b093cb0a4e4d7e323fdde9c06bcdf72e640c2 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:31:38 +0800 Subject: [PATCH 08/40] build: two-step for packages/vendor build and README --- AGENTS.md | 4 ++-- docs/cookbook/adding-a-package.md | 6 +++--- docs/cookbook/adding-a-vendored-package.md | 14 +++++++------- docs/development.md | 2 +- package.json | 1 + packages/README.md | 2 +- pnpm-lock.yaml | 20 ++++++++++++++++++-- tsconfig.base.json | 12 +++++------- tsdown.config.ts | 10 +++++----- vendor/logger-console/tsdown.config.ts | 11 ++++++----- vendor/schemastery/tsdown.config.ts | 8 ++++---- 11 files changed, 53 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0533c4cf0a..e7a6c9e7e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ pnpm run typecheck # tsc -b tsconfig.build.json (declarations) + tsc -p # tsconfig.typecheck.json (tests/examples typecheck too) pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix -pnpm run build # tsc -b tsconfig.build.json && tsdown (JS bundles into lib/) +pnpm run build # tsc emits lib/typings, then tsdown bundles runtime lib/index.* pnpm run knip # dead-code / unused-dependency check pnpm run publint # package.json publish-correctness check (publishable packages/*) pnpm run hygiene # knip + publint + workspace constraints @@ -126,7 +126,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js ## Conventions - **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-` (vendored packages keep their upstream names and are `private: true`). -- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package imports use explicit `.ts` extensions (allowImportingTsExtensions). +- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports are extensionless so generated `.d.ts` files stay extensionless; `lib/typings/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. - **`cordis` is a peerDependency** (+ devDependency) of every harness package, mirroring upstream convention. - **Registrations are effects**: anything a plugin contributes (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so disposal and HMR work. If you write a registry, `register()` must return the disposer. - **Typed events via declaration merging**: services declare their events in `declare module 'cordis' { interface Events { … } }`, and their ctx key in `interface Context`. Extensible unions use the merge-extensible-map pattern (see `ContentBlockMap`, `MessageSourceMap`). diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index ed40f242e4..9ae8033fe3 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -7,7 +7,7 @@ The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verifie ``` packages// package.json # copy from packages/tools, adjust name/description/deps - tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib, + tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib/typings, # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery # if you use Config, + ../ for each dsh dependency) src/index.ts # service default export or plugin (name/inject/apply/Config) @@ -15,14 +15,14 @@ packages// README.md # service API, events, extension points, design notes ``` -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/typings/index.d.ts"`, `exports["."].types: "./lib/typings/index.d.ts"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/typings/**/*.d.ts`, and `src`; do not publish `lib/typings` JS/map intermediates or stale root declaration files. ## 2. Register it in the root configs | File | Change | |---|---| | `tsconfig.base.json` | add `"@deepseek-ai/dsh-": ["./packages//src"]` to `paths` | -| `tsconfig.typecheck.json` | same entry (this file overrides the map wholesale) | +| `tsconfig.json` | add `{ "path": "./packages/" }` to `references` | | `tsconfig.build.json` | add `{ "path": "./packages/" }` to `references` | | `scripts/publint-all.ts` | add `'packages/'` to the array | | `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm-deepseek`) | diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index fa3c754cb8..fb32d60d06 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -12,13 +12,13 @@ vendor// README.md LICENSE # if upstream ships them ``` -`tsconfig.json` mirrors the other vendored packages — `rootDir: src`, `outDir: lib`, the strictness relaxations upstream code needs, and a `references` entry for every other vendored package it imports: +`tsconfig.json` mirrors the other vendored packages — `rootDir: src`, `outDir: lib/typings`, the strictness relaxations upstream code needs, and a `references` entry for every other vendored package it imports: ```jsonc { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "src", "outDir": "lib", + "rootDir": "src", "outDir": "lib/typings", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false }, @@ -27,19 +27,19 @@ vendor// } ``` -`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/typings`, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). ## 2. Register it in the root configs | File | Change | |---|---| | `tsconfig.base.json` | add `"": ["./vendor//src"]` to `paths` | -| `tsconfig.typecheck.json` | add `"": ["./vendor//lib"]` — this file points at built declarations, not src. If the package's `types` entry isn't `lib/index.d.ts`, point at that built file instead (e.g. `logger-console` maps to `./vendor/logger-console/lib/shared`, matching its `"types": "lib/shared.d.ts"`). | +| `tsconfig.json` | add `{ "path": "./vendor/" }` to `references` | | `tsconfig.build.json` | add `{ "path": "./vendor/" }` to `references` (before the `packages/*` entries) | | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`). +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/typings`. ## 3. Mind the manifest guard @@ -49,8 +49,8 @@ Covered automatically by globs — no edits needed: root `package.json` workspac ```sh pnpm install # registers the workspace -pnpm run typecheck # the base→lib path split means: run once after a fresh add +pnpm run typecheck pnpm run build && pnpm run test && pnpm run constraints ``` -Note the `tsconfig` two-map split (called out in [AGENTS.md](../../AGENTS.md) § Secrets/.env): `lint`'s type-aware rules resolve vendored packages through their built `lib/` declarations, so run `pnpm run typecheck` (which builds them) once after adding the package or lint reports unresolved-type errors. +The source `paths` map is shared by build and root typecheck configs. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor//tsconfig.json`, not pulled into a root strict program. diff --git a/docs/development.md b/docs/development.md index 2270aa0436..ff81ac4079 100644 --- a/docs/development.md +++ b/docs/development.md @@ -98,7 +98,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README pnpm run doc-sync # doc-typecheck, event taxonomy, and markdown wrap verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale -pnpm run build # build declarations and JS bundles +pnpm run build # emit lib/typings intermediates, then bundle lib/index.* runtime files pnpm run hygiene # knip, publint, and workspace constraints ``` diff --git a/package.json b/package.json index 50319023cf..f8f79ee63e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", + "clean:build": "rm -rf .typecheck packages/*/lib vendor/*/lib *.tsbuildinfo", "typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/README.md b/packages/README.md index b9fe1aa5f1..25aabd038e 100644 --- a/packages/README.md +++ b/packages/README.md @@ -60,5 +60,5 @@ Each package has its own `README.md` with purpose, service API, events, extensio - **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism). - **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging. -- **ESM everywhere**; imports use package names across package boundaries, `.ts` extensions within a package. +- **ESM everywhere**; imports use package names across package boundaries and extensionless relative specifiers within a package. - **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5026104cd9..d44608e415 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 0.3.21 tsdown: specifier: ^0.22.2 - version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) + version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -2620,6 +2620,16 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unrun@0.3.1: + resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} + engines: {node: ^22.13.0 || >=24.0.0} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4933,7 +4943,7 @@ snapshots: optionalDependencies: typescript: 6.0.3 - tsdown@0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3): + tsdown@0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -4954,6 +4964,7 @@ snapshots: publint: 0.3.21 tsx: 4.22.4 typescript: 6.0.3 + unrun: 0.3.1 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -5015,6 +5026,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unrun@0.3.1: + dependencies: + rolldown: 1.1.1 + optional: true + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/tsconfig.base.json b/tsconfig.base.json index d26ab0d56d..56ccab1ccc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,12 +4,12 @@ "module": "esnext", "moduleResolution": "bundler", "declaration": true, - "emitDeclarationOnly": true, + "sourceMap": true, + "declarationMap": true, "composite": true, "incremental": true, "skipLibCheck": true, "esModuleInterop": true, - "allowImportingTsExtensions": true, "verbatimModuleSyntax": false, "strict": true, "noUncheckedIndexedAccess": true, @@ -19,11 +19,9 @@ "noUnusedLocals": true, "noUnusedParameters": true, "types": ["node"], - // Source-level resolution for the build graph: without this, a fresh - // checkout's first `tsc -b` resolves sibling vendor plugins through their - // package.json types (vendor/*/lib/*.d.ts) which don't exist yet — TS2307 - // until a second run. Derived configs that want lib resolution - // (tsconfig.typecheck.json) override this map wholesale. + // Source-level resolution for every repo-local graph. Project references, + // not declaration path aliases, keep each package/vendor source compiled + // under its own tsconfig boundary. "paths": { "cordis": ["./vendor/cordis/src"], "cosmokit": ["./vendor/cosmokit/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 3b9039cc36..d6c3cca603 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from 'tsdown' /** - * JS bundling for all workspace packages (vendor/* + packages/*). - * Declarations are NOT produced here — `tsc -b tsconfig.build.json` owns - * .d.ts output (composite project references); hence `dts: false` and - * `clean: false` (lib/ already holds tsc's declarations). + * Runtime bundling for all workspace packages (vendor/* + packages/*). + * TypeScript source is compiled first by `tsc -b tsconfig.build.json`; tsdown + * reads only the emitted JS under lib/typings and writes lib/index.* runtime + * bundles. Declarations are NOT produced here, hence `dts: false`. * * Per-package shape overrides live in `/tsdown.config.ts` * (schemastery: dual ESM+CJS; logger-console: extra browser entry). @@ -13,7 +13,7 @@ export default defineConfig({ // Explicit globs: `workspace: true` would also discover examples/* (any // package.json), but only vendor/* and packages/* are pnpm workspaces. workspace: ['vendor/*', 'packages/*'], - entry: ['src/index.ts'], + entry: ['lib/typings/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/logger-console/tsdown.config.ts b/vendor/logger-console/tsdown.config.ts index 3d9b213b6b..c85dad4a28 100644 --- a/vendor/logger-console/tsdown.config.ts +++ b/vendor/logger-console/tsdown.config.ts @@ -3,9 +3,10 @@ import { defineConfig } from 'tsdown' /** * logger-console ships two entries: the node exporter (index) and the * browser exporter (browser), selected via package.json `exports` - * conditions. They are built as two single-entry passes so the shared - * base class is inlined into each (matching upstream's published shape) - * instead of split into a hash-named chunk. + * conditions. The entries are JS emitted by tsc under lib/typings and are + * bundled as two single-entry passes so the shared base class is inlined into + * each (matching upstream's published shape) instead of split into a hash-named + * chunk. */ const shared = { outDir: 'lib', @@ -18,6 +19,6 @@ const shared = { } as const export default defineConfig([ - { ...shared, entry: ['src/index.ts'] }, - { ...shared, entry: ['src/browser.ts'] }, + { ...shared, entry: ['lib/typings/index.js'] }, + { ...shared, entry: ['lib/typings/browser.js'] }, ]) diff --git a/vendor/schemastery/tsdown.config.ts b/vendor/schemastery/tsdown.config.ts index 43b4384a06..b16c217750 100644 --- a/vendor/schemastery/tsdown.config.ts +++ b/vendor/schemastery/tsdown.config.ts @@ -2,12 +2,12 @@ import { defineConfig } from 'tsdown' /** * schemastery has no `"type": "module"` and publishes dual-format output - * (package.json: main → lib/index.cjs, module → lib/index.mjs). Pin the - * extensions explicitly — the defaults for a CommonJS package would emit - * .mjs/.js instead. + * (package.json: main → lib/index.cjs, module → lib/index.mjs). The entry is + * the JS emitted by tsc under lib/typings; pin the bundled extensions + * explicitly because the defaults for a CommonJS package would emit .mjs/.js. */ export default defineConfig({ - entry: ['src/index.ts'], + entry: ['lib/typings/index.js'], outDir: 'lib', format: ['esm', 'cjs'], platform: 'node', From dc04fea749161c2ebdac94fff908de3dca21df61 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:41:18 +0800 Subject: [PATCH 09/40] feat: one tsconfig.json and different rules --- AGENTS.md | 8 +-- docs/development.md | 9 +-- eslint.config.mjs | 6 +- examples/acp-agent/tests/acp.snapshot.ts | 4 +- .../tests/snapshot-normalize.spec.ts | 2 +- .../coding-agent/tests/coding-task.e2e.ts | 2 +- examples/coding-agent/tests/full-loop.e2e.ts | 2 +- examples/coding-agent/tests/resume.e2e.ts | 2 +- package.json | 2 +- scripts/doc-typecheck.ts | 63 ++++++++----------- tsconfig.json | 40 +++++++++++- tsconfig.test.json | 10 --- tsconfig.typecheck.json | 40 ------------ vitest.config.ts | 8 +-- vitest.e2e.config.ts | 2 +- vitest.snapshot.config.ts | 2 +- 16 files changed, 89 insertions(+), 113 deletions(-) delete mode 100644 tsconfig.test.json delete mode 100644 tsconfig.typecheck.json diff --git a/AGENTS.md b/AGENTS.md index e7a6c9e7e9..4bb814cab7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,8 +84,7 @@ pnpm run test:snapshot # ACP snapshot tests (examples/*/tests/**/*.snapshot.ts) pnpm run test:snapshot:record # re-record fixtures + goldens against the real # API (needs DEEPSEEK_API_KEY); accept-the-diff = re-record # (or `pnpm run test:snapshot -u` to refresh goldens only) -pnpm run typecheck # tsc -b tsconfig.build.json (declarations) + tsc -p - # tsconfig.typecheck.json (tests/examples typecheck too) +pnpm run typecheck # tsc -b tsconfig.json pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run build # tsc emits lib/typings, then tsdown bundles runtime lib/index.* @@ -98,7 +97,8 @@ pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/archit # matches the interface Events declarations in source pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/README.md, AGENTS.md (one line per paragraph) -pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap (CI runs this) +pnpm run verify-md-links # assert relative Markdown links resolve in checked docs +pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -121,7 +121,7 @@ cordis.yml configs reference env vars with the `!!js` tag: `apiKey: !!js process **Lean on with-key e2e tests — we are DeepSeek and model inference is cheap.** A no-key test (mock adapter, or an operation that never reaches the model) is great for determinism and CI, but it can only prove the plumbing, not that the agent actually *works* against a real model. Do not ration real-API tests to save tokens: write many of them, cover the real flows (a real prompt that writes a file, a multi-turn conversation, tool use, cancellation mid-stream), and run them frequently while developing — locally and whenever you have a key in the environment. **Especially smoke tests**: a cheap with-key smoke test that boots the real example, sends one real prompt, and checks the world (a file on disk, a non-empty assistant turn) catches whole classes of "green unit tests, broken product" failures that mocks structurally cannot — the very gap that let the ACP inject bug ship (see [docs/postmortem/0001](docs/postmortem/0001-acp-default-export-drops-inject.md)). The self-skip rule is ONLY so CI (which has no secrets) stays green and so a contributor without a key isn't blocked — it is not a signal that real-API tests are expensive or second-class. When in doubt, add the with-key test AND run it. -Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.json` (`vitest` resolves through `tsconfig.test.json`). Building is only needed for publishing/consumption outside the repo — with one exception: `pnpm run lint`'s type-aware rules resolve vendor packages through their built declarations (`tsconfig.typecheck.json` → `vendor/*/lib`), so run `pnpm run typecheck` once after a fresh clone (CI does the same) or lint reports unresolved-type `no-unsafe-*` errors. +Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsconfig.json` (`vitest` resolves through that same root config). Building is only needed for publishing/consumption outside the repo. Non-published code (`examples`, tests, and scripts) is checked by root `tsconfig.json`, which sets `noEmit` and references the package/vendor graph so those sources stay checked under their own tsconfig boundaries. ## Conventions diff --git a/docs/development.md b/docs/development.md index ff81ac4079..700b25251d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,7 +31,7 @@ Run typecheck once after a fresh clone: pnpm run typecheck ``` -That first typecheck builds declaration output used by type-aware linting for vendored packages. Without it, `pnpm run lint` can report unresolved-type `no-unsafe-*` errors even when source code is fine. +That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings. If you are preparing to push from a fresh clone or worktree, also build once: @@ -89,20 +89,21 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run typecheck # build declarations, then typecheck source, tests, and examples +pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown -pnpm run doc-sync # doc-typecheck, event taxonomy, and markdown wrap verification +pnpm run verify-md-links # fail on broken relative Markdown links in checked docs +pnpm run doc-sync # doc-typecheck, event taxonomy, markdown wrap, and link verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # emit lib/typings intermediates, then bundle lib/index.* runtime files pnpm run hygiene # knip, publint, and workspace constraints ``` -When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, event-taxonomy drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. +When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, event-taxonomy drift, hard-wrapped markdown prose, and broken relative Markdown links, but broader prose/API sync still needs review. ## Demos diff --git a/eslint.config.mjs b/eslint.config.mjs index a4f48af798..6e73e9418b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,7 +36,7 @@ export default tseslint.config( ], languageOptions: { parserOptions: { - project: ['./tsconfig.typecheck.json'], + project: ['./packages/*/tsconfig.json', './tsconfig.json'], tsconfigRootDir: import.meta.dirname, }, }, @@ -81,13 +81,13 @@ export default tseslint.config( // --- tests: same rules, minus the friction that fights test ergonomics -- { - files: ['packages/*/tests/**/*.ts'], + files: ['packages/*/tests/**/*.ts', 'examples/*/tests/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], languageOptions: { parserOptions: { - project: ['./tsconfig.typecheck.json'], + project: ['./tsconfig.json'], tsconfigRootDir: import.meta.dirname, }, }, diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9f99ce9913..106586d099 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -3,8 +3,8 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { type InputScript, runScenario } from './snapshot-harness.ts' -import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize.ts' +import { type InputScript, runScenario } from './snapshot-harness' +import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize' /** * ACP snapshot tests (REPLAY by default, keyless). Each scenario under diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index bfdeab5dc8..33c1cabaea 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from '../tests/snapshot-normalize.ts' +import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from '../tests/snapshot-normalize' /** * Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index 4684301725..e8a34950cb 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness' /** * The swebench-style smoke test: a real model fixes a real bug in a temp diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 93bc0b1fac..e73ae25236 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness' /** * The first place a REAL model meets the REAL bash tool: the cheap canary diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index b720efa8c9..b5b7898830 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness' /** * Proves durable conversation continuity end-to-end: run 1 tells the REAL model diff --git a/package.json b/package.json index f8f79ee63e..a1394cb9e5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", "clean:build": "rm -rf .typecheck packages/*/lib vendor/*/lib *.tsbuildinfo", - "typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json", + "typecheck": "tsc -b tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 10068eb792..da86488e04 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -3,12 +3,12 @@ * Markdown so documentation can't drift from the API it documents. * * Every ```ts block in README.md, docs/** and packages/* /README.md is - * extracted to a temp file and compiled with `tsc --noEmit` against the - * workspace sources (resolved through the same `paths` map vitest uses, so no - * build is required first). A block that is a deliberate sketch rather than - * compilable code opts out with an explicit ` ```ts ignore-check ` info string - * — the opt-out is visible in the source, and this script reports the ratio so - * the escape hatch can't quietly become the norm. + * extracted to a temp typecheck project and compiled against the workspace + * sources through the same project-reference boundaries used by repo + * typecheck. A block that is a deliberate sketch rather than compilable code + * opts out with an explicit ` ```ts ignore-check ` info string — the opt-out + * is visible in the source, and this script reports the ratio so the escape + * hatch can't quietly become the norm. * * Run: `tsx scripts/doc-typecheck.ts`. */ @@ -59,41 +59,27 @@ function extractBlocks(absPath: string): Block[] { return blocks } -/** - * Read the workspace `paths` map from tsconfig.typecheck.json (JSONC). This map - * resolves vendored packages to their BUILT declarations (`lib`) and harness - * packages to source (`src`) — the same resolution `pnpm run lint`/`typecheck` use. - * Resolving vendor to `lib` (not `src`) is essential: otherwise tsc type-checks - * raw vendor source and floods the run with unrelated errors. Requires the - * vendor `lib/` to exist (a fresh clone runs `pnpm run build` first; CI does too). - */ -function workspacePaths(): Record { - const raw = readFileSync(join(root, 'tsconfig.typecheck.json'), 'utf8') - // Strip // line comments and /* */ block comments so JSON.parse accepts it. - const stripped = raw - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/.*$/gm, '$1') - return (JSON.parse(stripped) as { compilerOptions: { paths: Record } }) - .compilerOptions.paths +/** Reuse the repo typecheck graph references from a temp project one directory below root. */ +function workspaceReferences(): { path: string }[] { + const raw = readFileSync(join(root, 'tsconfig.json'), 'utf8') + const { references } = JSON.parse(raw) as { references: { path: string }[] } + return references.map(({ path }) => { + const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}` + return { path: relativeToTemp } + }) } -/** The standalone tsconfig for the temp project (copies base resolution, no - * composite/declaration settings that would fight `--noEmit`). */ +/** The standalone tsconfig for the temp typecheck project. */ function tempTsconfig(): string { return JSON.stringify({ + extends: '../tsconfig.json', compilerOptions: { - target: 'es2024', - module: 'esnext', - moduleResolution: 'bundler', - allowImportingTsExtensions: true, - strict: true, - noEmit: true, - skipLibCheck: true, - types: ['node'], - baseUrl: root, - ignoreDeprecations: '6.0', - paths: workspacePaths(), + noUnusedLocals: false, + noUnusedParameters: false, + tsBuildInfoFile: './tsconfig.tsbuildinfo', }, + include: ['block-*.ts'], + references: workspaceReferences(), }) } @@ -125,11 +111,12 @@ try { }) try { - execFileSync('node_modules/.bin/tsc', ['-p', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) + execFileSync('node_modules/.bin/tsc', ['-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) } catch (error: unknown) { - const out = (error as { stdout?: Buffer }).stdout?.toString() ?? '' + const failed = error as { stdout?: Buffer; stderr?: Buffer } + const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}` // Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage. - const remapped = out.replace(/block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => { + const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => { const block = fileForBlock.get(`block-${idx}.ts`) if (!block) return `block-${idx}.ts(${ln},${col})` return `${block.file} (block at line ${block.line}, +${ln}:${col})` diff --git a/tsconfig.json b/tsconfig.json index 725f31659f..61268f8bf9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,42 @@ { "extends": "./tsconfig.base.json", - "files": [] + "compilerOptions": { + "noEmit": true + }, + "include": [ + "examples/*/src/**/*.ts", + "examples/*/start.ts", + "examples/*/tests/**/*.ts", + "packages/*/tests/**/*.ts", + "scripts/**/*.ts" + ], + "references": [ + { "path": "./vendor/cosmokit" }, + { "path": "./vendor/schemastery" }, + { "path": "./vendor/cordis" }, + { "path": "./vendor/loader" }, + { "path": "./vendor/include" }, + { "path": "./vendor/group" }, + { "path": "./vendor/timer" }, + { "path": "./vendor/hmr" }, + { "path": "./vendor/logger-console" }, + { "path": "./packages/llm" }, + { "path": "./packages/session" }, + { "path": "./packages/session-persistence" }, + { "path": "./packages/session-persistence-jsonl" }, + { "path": "./packages/session-persistence-sqlite" }, + { "path": "./packages/system-prompt" }, + { "path": "./packages/agent" }, + { "path": "./packages/tools" }, + { "path": "./packages/agent-loop" }, + { "path": "./packages/bash" }, + { "path": "./packages/llm-deepseek" }, + { "path": "./packages/llm-pi-ai" }, + { "path": "./packages/bash-local" }, + { "path": "./packages/tool-bash" }, + { "path": "./packages/invariants" }, + { "path": "./packages/acp" }, + { "path": "./packages/ui-stdio" }, + { "path": "./packages/llm-replay" } + ] } diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index 5976d5b43c..0000000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "emitDeclarationOnly": false, - "composite": false, - "types": ["node"] - }, - "include": ["vendor/*/src", "packages/*/src", "packages/*/tests", "examples"] -} diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json deleted file mode 100644 index 77e2326775..0000000000 --- a/tsconfig.typecheck.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "emitDeclarationOnly": false, - "composite": false, - "incremental": false, - "types": ["node"], - "paths": { - "cordis": ["./vendor/cordis/lib"], - "cosmokit": ["./vendor/cosmokit/lib"], - "schemastery": ["./vendor/schemastery/lib"], - "@cordisjs/plugin-loader": ["./vendor/loader/lib"], - "@cordisjs/plugin-include": ["./vendor/include/lib"], - "@cordisjs/plugin-group": ["./vendor/group/lib"], - "@cordisjs/plugin-timer": ["./vendor/timer/lib"], - "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], - "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], - "@deepseek-ai/dsh-llm": ["./packages/llm/src"], - "@deepseek-ai/dsh-session": ["./packages/session/src"], - "@deepseek-ai/dsh-session-persistence": ["./packages/session-persistence/src"], - "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session-persistence-jsonl/src"], - "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-system-prompt": ["./packages/system-prompt/src"], - "@deepseek-ai/dsh-tools": ["./packages/tools/src"], - "@deepseek-ai/dsh-agent": ["./packages/agent/src"], - "@deepseek-ai/dsh-agent-loop": ["./packages/agent-loop/src"], - "@deepseek-ai/dsh-bash": ["./packages/bash/src"], - "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], - "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], - "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"], - "@deepseek-ai/dsh-acp": ["./packages/acp/src"], - "@deepseek-ai/dsh-ui-stdio": ["./packages/ui-stdio/src"], - "@deepseek-ai/dsh-llm-replay": ["./packages/llm-replay/src"] - } - }, - "include": ["packages/*/src", "packages/*/tests", "examples", "scripts"] -} diff --git a/vitest.config.ts b/vitest.config.ts index 0878477fb4..6d8608d86e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,9 +5,9 @@ export default defineConfig({ // Vite ≥8 warns that this plugin can be replaced by the native (experimental) // `resolve.tsconfigPaths: true`. It cannot — keep the plugin. Tests run // unbuilt (see AGENTS.md): bare workspace names like `cordis` or - // `@deepseek-ai/dsh-llm` must resolve to src/, and the only place that - // mapping exists is the root tsconfig.json `paths` map inherited by - // tsconfig.test.json. The native option is a bare boolean: for each + // `@deepseek-ai/dsh-llm` must resolve to src/, and that mapping comes from + // the root tsconfig.json paths map. The native option is a bare boolean: + // for each // importing file it discovers the NEAREST tsconfig.json and applies that // file's own `paths`. Every workspace under packages/* and vendor/* has its // own tsconfig.json without `paths`, so native resolution maps nothing, @@ -17,7 +17,7 @@ export default defineConfig({ // 15 workspace tsconfigs — including vendor/* ones, which are pinned // upstream copies (vendor/README.md). The plugin's `projects` option // instead applies the one root map to every importer. - plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], coverage: { diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 9316d660da..903e38cafb 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -22,7 +22,7 @@ try { export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the root tsconfig paths map; the native option cannot do this. - plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 6dc4144044..ecc8d911aa 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -24,7 +24,7 @@ if (process.env.DSH_SNAPSHOT === 'record') { export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the root tsconfig paths map; the native option cannot do this. - plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['examples/*/tests/**/*.snapshot.ts'], // Each test boots a subprocess; give it room, and run files one at a time From c7e55fc0b17bf28572238e73dc0025e966349c59 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 17 Jun 2026 23:42:45 +0800 Subject: [PATCH 10/40] fix: change adr history to current tsconfig behavior --- docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md | 6 ++++-- docs/rfc/implemented/2026-06-11-quality-gates.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md index 68e6f0a03d..51aff694b8 100644 --- a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md @@ -12,13 +12,15 @@ AGENTS.md promises that docs and code stay strictly in sync, but the promise was Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each): -1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project, and compiles with `tsc --noEmit`. The temp tsconfig copies only resolution-relevant options and the workspace `paths` map from `tsconfig.typecheck.json` (vendor → built `lib`, harness → `src`) — resolving vendor to `lib` is essential, or tsc type-checks raw vendor source and floods the run. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. +1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. 2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) -Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck` (which emits the vendor `lib/` that doc-typecheck resolves against). API-extractor golden reports ([the deferred API-extractor-reports proposal](../proposed/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. +Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references. API-extractor golden reports ([the deferred API-extractor-reports proposal](../proposed/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. **Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the AGENTS.md "Markdown is not hard-wrapped" convention. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. +**Amendment (2026-06-18):** a fourth gate, **`verify-md-links`**, was later folded into `doc-sync` by the [Markdown cross-link validity linting RFC](2026-06-18-markdown-cross-link-lint.md). It checks that every relative Markdown link in the checked docs resolves to an existing file, so the RFC tree can use date-based filenames and relative links instead of stale numeric prose references. `doc-sync` is now four gates. + ## Consequences - Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle. diff --git a/docs/rfc/implemented/2026-06-11-quality-gates.md b/docs/rfc/implemented/2026-06-11-quality-gates.md index 1f3dd3c520..70ec37bdf8 100644 --- a/docs/rfc/implemented/2026-06-11-quality-gates.md +++ b/docs/rfc/implemented/2026-06-11-quality-gates.md @@ -12,7 +12,7 @@ This codebase is developed primarily by coding agents. Agents follow enforced ga Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts: -- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via `tsconfig.typecheck.json` (vendored packages resolve as built declarations). +- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary. - ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded. - Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM). From ed94daed9ecc379631eb40ba1ef42295544a3460 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:21:57 +0800 Subject: [PATCH 11/40] fix: address build config review findings --- .github/workflows/ci.yml | 16 ++++----- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-vendored-package.md | 2 +- docs/rfc/README.md | 2 +- .../2026-06-11-tsdown-over-dumble.md | 6 ++-- ...onfig.md => 2026-06-17-ts-build-config.md} | 6 ++-- packages/acp/package.json | 1 + packages/agent-loop/package.json | 1 + packages/agent/package.json | 1 + packages/bash-local/package.json | 1 + packages/bash/package.json | 1 + packages/invariants/package.json | 1 + packages/llm-deepseek/package.json | 1 + packages/llm-pi-ai/package.json | 1 + packages/llm-replay/package.json | 1 + packages/llm/package.json | 1 + .../session-persistence-jsonl/package.json | 1 + .../session-persistence-sqlite/package.json | 1 + packages/session-persistence/package.json | 1 + packages/session/package.json | 1 + packages/system-prompt/package.json | 1 + packages/tool-bash/package.json | 1 + packages/tools/package.json | 1 + packages/ui-stdio/package.json | 1 + scripts/check-workspace-constraints.ts | 35 +++++++++++++++++++ scripts/doc-typecheck.ts | 9 ++++- vendor/README.md | 4 +-- vendor/cordis/package.json | 1 + vendor/cosmokit/package.json | 1 + vendor/group/package.json | 1 + vendor/hmr/package.json | 1 + vendor/include/package.json | 1 + vendor/loader/package.json | 1 + vendor/logger-console/package.json | 1 + vendor/schemastery/package.json | 1 + vendor/timer/package.json | 1 + 36 files changed, 88 insertions(+), 21 deletions(-) rename docs/rfc/implemented/{2026-06-20-ts-build-config.md => 2026-06-17-ts-build-config.md} (89%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b9a9fca4f..e35f69513a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,22 +33,20 @@ jobs: - name: Constraints run: pnpm run constraints - # Before lint: the type-aware ESLint config resolves vendor packages via - # their built declarations (tsconfig.typecheck.json -> vendor/*/lib), - # which `pnpm run typecheck` emits. Lint on a fresh checkout would otherwise - # see unresolved types and erupt with no-unsafe-* errors. + # Before lint: root typecheck validates the package/vendor reference graph + # and refreshes TSC intermediates so type-aware ESLint sees the same project + # boundaries as the build. - name: Typecheck (src + tests + examples) run: pnpm run typecheck - name: Lint run: pnpm run lint - # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the fenced ts blocks in - # the docs and resolves vendor packages via their built declarations, which - # the typecheck step above emits — so it runs after typecheck. The event - # taxonomy check and the markdown wrap check only read source. Same + # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the + # fenced ts blocks against the root project-reference graph. The event + # taxonomy, markdown wrap, and markdown link checks only read source. Same # `doc-sync` script the pre-push hook runs (quality-gates RFC: one source of truth). - - name: Doc-sync gates (doc code blocks + event taxonomy + markdown wrap) + - name: Doc-sync gates (doc code blocks + event taxonomy + markdown) run: pnpm run doc-sync # Module-graph freshness: regenerate docs/module-graph.md from the diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 9ae8033fe3..c9962f27a1 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -15,7 +15,7 @@ packages// README.md # service API, events, extension points, design notes ``` -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/typings/index.d.ts"`, `exports["."].types: "./lib/typings/index.d.ts"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/typings/**/*.d.ts`, and `src`; do not publish `lib/typings` JS/map intermediates or stale root declaration files. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/typings/index.d.ts"`, `exports["."].types: "./lib/typings/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/typings/**/*.d.ts`, `lib/typings/**/*.d.ts.map`, and `src`; do not publish `lib/typings` JS/map intermediates or stale root declaration files. ## 2. Register it in the root configs diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index fb32d60d06..ed54a0a578 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -27,7 +27,7 @@ vendor// } ``` -`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/typings`, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/typings`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). ## 2. Register it in the root configs diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 88406034d5..dcb9dab289 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -60,7 +60,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | | [ACP snapshot tests — record-once / replay-deterministic](implemented/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | -| [TSC-first build and one tsconfig](implemented/2026-06-20-ts-build-config.md) | 2026-06-20 | +| [TSC-first build and one tsconfig](implemented/2026-06-17-ts-build-config.md) | 2026-06-17 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md index 283b236397..bd69ebbf31 100644 --- a/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md @@ -15,12 +15,12 @@ Build output currently matters only for `pnpm run build` + publint (nothing publ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released): - Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` (explicit globs, not `workspace: true`, which would also pick up `examples/*` — they have package.json files but are not pnpm workspaces). -- Shared shape: entry `src/index.ts`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ holds tsc's .d.ts output). +- Shared shape: entry `lib/typings/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/typings` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build RFC](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). -- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b && tsdown`. +- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. Alternatives considered: **direct esbuild script** (most established engine, zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us); **pkgroll** (closest drop-in philosophically, but 78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown); **keep dumble** (perfect upstream alignment, unacceptable bus factor). ## Consequences -Output file lists are byte-for-byte-list identical to dumble's (verified by snapshot diff at migration time); externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new RFC. +Runtime bundle outputs still follow the dumble-era public entry shape (`lib/index.js`, plus package-specific variants such as `schemastery`'s `lib/index.mjs`/`lib/index.cjs` and `logger-console`'s `lib/browser.js`); declarations now live under `lib/typings` per the [TSC-first build RFC](2026-06-17-ts-build-config.md). Externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new RFC. diff --git a/docs/rfc/implemented/2026-06-20-ts-build-config.md b/docs/rfc/implemented/2026-06-17-ts-build-config.md similarity index 89% rename from docs/rfc/implemented/2026-06-20-ts-build-config.md rename to docs/rfc/implemented/2026-06-17-ts-build-config.md index ab56b07e83..9c64de454f 100644 --- a/docs/rfc/implemented/2026-06-20-ts-build-config.md +++ b/docs/rfc/implemented/2026-06-17-ts-build-config.md @@ -30,14 +30,14 @@ In-package relative imports are extensionless. `pnpm run build` is a two-stage build: -- Stage 1: `tsc -b tsconfig.build.json` emits publishable per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/typings`. This is the authoritative TypeScript compilation result. For publish we should keep `.d.ts` and ignore `.js` / `.js.map` / `.d.ts.map` +- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/typings`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. - The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results. - Stage 2: a bundler reads the emitted JS under `lib/typings` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. `tsdown` is no longer the owner of TypeScript compilation or declaration output. `pnpm run typecheck` runs build mode over the root `tsconfig.json`. -- The root `tsconfig.json` is the single development/typecheck project. It has `noEmit` for demos, examples, tests, and scripts, and validates package/vendor source through references. +- The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/typings` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/tsconfig.json` or `vendor/*/tsconfig.json`. The command orchestration shape is: @@ -59,7 +59,7 @@ Build responsibilities are clearer: - Each module under `packages/*` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. - The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - - `lib/typings/*.d.ts` is the publish declaration output. + - `lib/typings/*.d.ts` and `.d.ts.map` are the publish declaration output. - `lib/typings/*.js` is only a bundler input and must not be used as a runtime entry or public import target. - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. diff --git a/packages/acp/package.json b/packages/acp/package.json index ac23174ade..9f052e5753 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/agent-loop/package.json b/packages/agent-loop/package.json index 9e54e4de4b..ae7296d4df 100644 --- a/packages/agent-loop/package.json +++ b/packages/agent-loop/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/agent/package.json b/packages/agent/package.json index bca0ff7840..eb3a967338 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash-local/package.json b/packages/bash-local/package.json index de2f2d6c1a..7a8f6fb2a4 100644 --- a/packages/bash-local/package.json +++ b/packages/bash-local/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash/package.json b/packages/bash/package.json index f65f5a6a7f..8f33a4ccff 100644 --- a/packages/bash/package.json +++ b/packages/bash/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/invariants/package.json b/packages/invariants/package.json index 409596871b..20ffc74bbf 100644 --- a/packages/invariants/package.json +++ b/packages/invariants/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-deepseek/package.json b/packages/llm-deepseek/package.json index 0517a0c5a9..0da7a35b5e 100644 --- a/packages/llm-deepseek/package.json +++ b/packages/llm-deepseek/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-pi-ai/package.json b/packages/llm-pi-ai/package.json index f2e9a34322..d212037a95 100644 --- a/packages/llm-pi-ai/package.json +++ b/packages/llm-pi-ai/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-replay/package.json b/packages/llm-replay/package.json index b25fa04f03..d9569c2d02 100644 --- a/packages/llm-replay/package.json +++ b/packages/llm-replay/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm/package.json b/packages/llm/package.json index 835e89af7f..6a01d52c6c 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence-jsonl/package.json b/packages/session-persistence-jsonl/package.json index 6a1e61c361..8620858548 100644 --- a/packages/session-persistence-jsonl/package.json +++ b/packages/session-persistence-jsonl/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence-sqlite/package.json b/packages/session-persistence-sqlite/package.json index 4d6951ebbd..ad5cec37d6 100644 --- a/packages/session-persistence-sqlite/package.json +++ b/packages/session-persistence-sqlite/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence/package.json b/packages/session-persistence/package.json index 901381cffc..17b3c7a796 100644 --- a/packages/session-persistence/package.json +++ b/packages/session-persistence/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session/package.json b/packages/session/package.json index d660624853..42ef62567e 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json index b5782f2c38..7e419ed29a 100644 --- a/packages/system-prompt/package.json +++ b/packages/system-prompt/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/tool-bash/package.json b/packages/tool-bash/package.json index e433fad938..23baf69058 100644 --- a/packages/tool-bash/package.json +++ b/packages/tool-bash/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/tools/package.json b/packages/tools/package.json index 0a578547f2..c78caf0f51 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui-stdio/package.json b/packages/ui-stdio/package.json index 34216be977..8e7c54454f 100644 --- a/packages/ui-stdio/package.json +++ b/packages/ui-stdio/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 50b1a80078..263be4af5a 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -28,6 +28,15 @@ interface PackageManifest { version?: string private?: boolean type?: string + main?: string + types?: string + exports?: { + '.'?: { + types?: string + default?: string + } + } + files?: string[] peerDependencies?: Record devDependencies?: Record } @@ -58,6 +67,17 @@ function workspaceManifests(): WorkspaceManifest[] { return manifests } +const dshPackageFiles = [ + 'lib/index.js', + 'lib/typings/**/*.d.ts', + 'lib/typings/**/*.d.ts.map', + 'src', +] as const + +function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean { + return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index]) +} + function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { const errors: string[] = [] const label = manifest.name ?? dir @@ -85,6 +105,21 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.type !== 'module') { errors.push(`${label}: package.json must set "type": "module"`) } + if (manifest.main !== 'lib/index.js') { + errors.push(`${label}: package.json must set "main": "lib/index.js"`) + } + if (manifest.types !== 'lib/typings/index.d.ts') { + errors.push(`${label}: package.json must set "types": "lib/typings/index.d.ts"`) + } + if (manifest.exports?.['.']?.types !== './lib/typings/index.d.ts') { + errors.push(`${label}: package.json exports["."].types must be "./lib/typings/index.d.ts"`) + } + if (manifest.exports?.['.']?.default !== './lib/index.js') { + errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) + } + if (!sameStringList(manifest.files, dshPackageFiles)) { + errors.push(`${label}: package.json files must be ${JSON.stringify(dshPackageFiles)}`) + } } return errors.map(error => `${relative(root, join(root, dir, 'package.json'))}: ${error}`) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index da86488e04..fde2e5d60a 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -30,6 +30,13 @@ interface Block { code: string } +/** Strip JSONC comments from checked-in tsconfig files before JSON.parse. */ +function stripJsonComments(raw: string): string { + return raw + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1') +} + /** Extract every ```ts / ```ts ignore-check block from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const text = readFileSync(absPath, 'utf8') @@ -62,7 +69,7 @@ function extractBlocks(absPath: string): Block[] { /** Reuse the repo typecheck graph references from a temp project one directory below root. */ function workspaceReferences(): { path: string }[] { const raw = readFileSync(join(root, 'tsconfig.json'), 'utf8') - const { references } = JSON.parse(raw) as { references: { path: string }[] } + const { references } = JSON.parse(stripJsonComments(raw)) as { references: { path: string }[] } return references.map(({ path }) => { const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}` return { path: relativeToTemp } diff --git a/vendor/README.md b/vendor/README.md index 87fbc46fb1..43c53cfb53 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -31,9 +31,9 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/typings/**/*.d.ts`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/typings`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. +2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/typings/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/typings`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/typings`, and declare project references. -4. **`loader/src/config/isolate.ts`**: changed the internal declaration merge specifier from `declare module './entry.ts'` to `declare module './entry'` so generated declarations are extensionless and no declaration postprocess is needed. +4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from explicit `.ts` / `.js` specifiers to extensionless specifiers so generated `.js` and `.d.ts` intermediates are extensionless and no declaration postprocess is needed. This includes `loader/src/config/isolate.ts` changing `declare module './entry.ts'` to `declare module './entry'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/typings` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. ## Sync procedure diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 33bd881dc2..59c3f69649 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -19,6 +19,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "bin.js" ], "author": "Shigma ", diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index ccb8f620fd..d313ce5477 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/group/package.json b/vendor/group/package.json index cd638f59a7..d8d56c7675 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 1c3c088dd0..7bab5dd3d8 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/include/package.json b/vendor/include/package.json index 2b15cb4b90..0c91733947 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 8d43331708..75b45a89a3 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index f96f94b23d..33ec1d566a 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -19,6 +19,7 @@ "lib/index.js", "lib/browser.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 42aab72f69..8ce5cc5aff 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -10,6 +10,7 @@ "lib/index.mjs", "lib/index.cjs", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 8c7afeafc4..ff68a84aa0 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -17,6 +17,7 @@ "files": [ "lib/index.js", "lib/typings/**/*.d.ts", + "lib/typings/**/*.d.ts.map", "src" ], "author": "Shigma ", From 7f131dd4d8947185d87e575e26e568909b5bd3eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:26:02 +0800 Subject: [PATCH 12/40] refactor: rename build typings dir to types --- AGENTS.md | 4 ++-- docs/cookbook/adding-a-package.md | 4 ++-- docs/cookbook/adding-a-vendored-package.md | 8 ++++---- docs/development.md | 2 +- .../rfc/implemented/2026-06-11-tsdown-over-dumble.md | 4 ++-- docs/rfc/implemented/2026-06-17-ts-build-config.md | 10 +++++----- packages/acp/package.json | 8 ++++---- packages/acp/tsconfig.json | 2 +- packages/agent-loop/package.json | 8 ++++---- packages/agent-loop/tsconfig.json | 2 +- packages/agent/package.json | 8 ++++---- packages/agent/tsconfig.json | 2 +- packages/bash-local/package.json | 8 ++++---- packages/bash-local/tsconfig.json | 2 +- packages/bash/package.json | 8 ++++---- packages/bash/tsconfig.json | 2 +- packages/invariants/package.json | 8 ++++---- packages/invariants/tsconfig.json | 2 +- packages/llm-deepseek/package.json | 8 ++++---- packages/llm-deepseek/tsconfig.json | 2 +- packages/llm-pi-ai/package.json | 8 ++++---- packages/llm-pi-ai/tsconfig.json | 2 +- packages/llm-replay/package.json | 8 ++++---- packages/llm-replay/tsconfig.json | 2 +- packages/llm/package.json | 8 ++++---- packages/llm/tsconfig.json | 2 +- packages/session-persistence-jsonl/package.json | 8 ++++---- packages/session-persistence-jsonl/tsconfig.json | 2 +- packages/session-persistence-sqlite/package.json | 8 ++++---- packages/session-persistence-sqlite/tsconfig.json | 2 +- packages/session-persistence/package.json | 8 ++++---- packages/session-persistence/tsconfig.json | 2 +- packages/session/package.json | 8 ++++---- packages/session/tsconfig.json | 2 +- packages/system-prompt/package.json | 8 ++++---- packages/system-prompt/tsconfig.json | 2 +- packages/tool-bash/package.json | 8 ++++---- packages/tool-bash/tsconfig.json | 2 +- packages/tools/package.json | 8 ++++---- packages/tools/tsconfig.json | 2 +- packages/ui-stdio/package.json | 8 ++++---- packages/ui-stdio/tsconfig.json | 2 +- scripts/check-workspace-constraints.ts | 12 ++++++------ tsdown.config.ts | 4 ++-- vendor/README.md | 6 +++--- vendor/cordis/package.json | 8 ++++---- vendor/cordis/tsconfig.json | 2 +- vendor/cosmokit/package.json | 8 ++++---- vendor/cosmokit/tsconfig.json | 2 +- vendor/group/package.json | 8 ++++---- vendor/group/tsconfig.json | 2 +- vendor/hmr/package.json | 8 ++++---- vendor/hmr/tsconfig.json | 2 +- vendor/include/package.json | 8 ++++---- vendor/include/tsconfig.json | 2 +- vendor/loader/package.json | 8 ++++---- vendor/loader/tsconfig.json | 2 +- vendor/logger-console/package.json | 8 ++++---- vendor/logger-console/tsconfig.json | 2 +- vendor/logger-console/tsdown.config.ts | 6 +++--- vendor/schemastery/package.json | 6 +++--- vendor/schemastery/tsconfig.json | 2 +- vendor/schemastery/tsdown.config.ts | 4 ++-- vendor/timer/package.json | 8 ++++---- vendor/timer/tsconfig.json | 2 +- 65 files changed, 166 insertions(+), 166 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4bb814cab7..f9631fed4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ pnpm run test:snapshot:record # re-record fixtures + goldens against the real pnpm run typecheck # tsc -b tsconfig.json pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix -pnpm run build # tsc emits lib/typings, then tsdown bundles runtime lib/index.* +pnpm run build # tsc emits lib/types, then tsdown bundles runtime lib/index.* pnpm run knip # dead-code / unused-dependency check pnpm run publint # package.json publish-correctness check (publishable packages/*) pnpm run hygiene # knip + publint + workspace constraints @@ -126,7 +126,7 @@ Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsco ## Conventions - **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-` (vendored packages keep their upstream names and are `private: true`). -- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports are extensionless so generated `.d.ts` files stay extensionless; `lib/typings/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. +- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports are extensionless so generated `.d.ts` files stay extensionless; `lib/types/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. - **`cordis` is a peerDependency** (+ devDependency) of every harness package, mirroring upstream convention. - **Registrations are effects**: anything a plugin contributes (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so disposal and HMR work. If you write a registry, `register()` must return the disposer. - **Typed events via declaration merging**: services declare their events in `declare module 'cordis' { interface Events { … } }`, and their ctx key in `interface Context`. Extensible unions use the merge-extensible-map pattern (see `ContentBlockMap`, `MessageSourceMap`). diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index c9962f27a1..dac46e4db6 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -7,7 +7,7 @@ The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verifie ``` packages// package.json # copy from packages/tools, adjust name/description/deps - tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib/typings, + tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib/types, # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery # if you use Config, + ../ for each dsh dependency) src/index.ts # service default export or plugin (name/inject/apply/Config) @@ -15,7 +15,7 @@ packages// README.md # service API, events, extension points, design notes ``` -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/typings/index.d.ts"`, `exports["."].types: "./lib/typings/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/typings/**/*.d.ts`, `lib/typings/**/*.d.ts.map`, and `src`; do not publish `lib/typings` JS/map intermediates or stale root declaration files. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. ## 2. Register it in the root configs diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index ed54a0a578..c45427e0b6 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -12,13 +12,13 @@ vendor// README.md LICENSE # if upstream ships them ``` -`tsconfig.json` mirrors the other vendored packages — `rootDir: src`, `outDir: lib/typings`, the strictness relaxations upstream code needs, and a `references` entry for every other vendored package it imports: +`tsconfig.json` mirrors the other vendored packages — `rootDir: src`, `outDir: lib/types`, the strictness relaxations upstream code needs, and a `references` entry for every other vendored package it imports: ```jsonc { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "src", "outDir": "lib/typings", + "rootDir": "src", "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false }, @@ -27,7 +27,7 @@ vendor// } ``` -`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/typings`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). ## 2. Register it in the root configs @@ -39,7 +39,7 @@ vendor// | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/typings`. +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. ## 3. Mind the manifest guard diff --git a/docs/development.md b/docs/development.md index 700b25251d..83d1cb3fac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -99,7 +99,7 @@ pnpm run verify-md-links # fail on broken relative Markdown links in checked do pnpm run doc-sync # doc-typecheck, event taxonomy, markdown wrap, and link verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale -pnpm run build # emit lib/typings intermediates, then bundle lib/index.* runtime files +pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files pnpm run hygiene # knip, publint, and workspace constraints ``` diff --git a/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md index bd69ebbf31..dc028d7e9b 100644 --- a/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/2026-06-11-tsdown-over-dumble.md @@ -15,7 +15,7 @@ Build output currently matters only for `pnpm run build` + publint (nothing publ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released): - Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` (explicit globs, not `workspace: true`, which would also pick up `examples/*` — they have package.json files but are not pnpm workspaces). -- Shared shape: entry `lib/typings/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/typings` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build RFC](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. +- Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build RFC](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). - `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. @@ -23,4 +23,4 @@ Alternatives considered: **direct esbuild script** (most established engine, zer ## Consequences -Runtime bundle outputs still follow the dumble-era public entry shape (`lib/index.js`, plus package-specific variants such as `schemastery`'s `lib/index.mjs`/`lib/index.cjs` and `logger-console`'s `lib/browser.js`); declarations now live under `lib/typings` per the [TSC-first build RFC](2026-06-17-ts-build-config.md). Externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new RFC. +Runtime bundle outputs still follow the dumble-era public entry shape (`lib/index.js`, plus package-specific variants such as `schemastery`'s `lib/index.mjs`/`lib/index.cjs` and `logger-console`'s `lib/browser.js`); declarations now live under `lib/types` per the [TSC-first build RFC](2026-06-17-ts-build-config.md). Externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new RFC. diff --git a/docs/rfc/implemented/2026-06-17-ts-build-config.md b/docs/rfc/implemented/2026-06-17-ts-build-config.md index 9c64de454f..6c5a11156f 100644 --- a/docs/rfc/implemented/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/2026-06-17-ts-build-config.md @@ -30,15 +30,15 @@ In-package relative imports are extensionless. `pnpm run build` is a two-stage build: -- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/typings`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. +- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. - The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results. -- Stage 2: a bundler reads the emitted JS under `lib/typings` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. +- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. `tsdown` is no longer the owner of TypeScript compilation or declaration output. `pnpm run typecheck` runs build mode over the root `tsconfig.json`. - The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/typings` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/tsconfig.json` or `vendor/*/tsconfig.json`. +- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/tsconfig.json` or `vendor/*/tsconfig.json`. The command orchestration shape is: @@ -59,8 +59,8 @@ Build responsibilities are clearer: - Each module under `packages/*` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. - The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - - `lib/typings/*.d.ts` and `.d.ts.map` are the publish declaration output. - - `lib/typings/*.js` is only a bundler input and must not be used as a runtime entry or public import target. + - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. + - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. diff --git a/packages/acp/package.json b/packages/acp/package.json index 9f052e5753..6973dc5e20 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/acp/tsconfig.json b/packages/acp/tsconfig.json index 73d850e990..75c578f9ec 100644 --- a/packages/acp/tsconfig.json +++ b/packages/acp/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/agent-loop/package.json b/packages/agent-loop/package.json index ae7296d4df..6e92adb6ab 100644 --- a/packages/agent-loop/package.json +++ b/packages/agent-loop/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/agent-loop/tsconfig.json b/packages/agent-loop/tsconfig.json index 93a07b2e41..afec654d20 100644 --- a/packages/agent-loop/tsconfig.json +++ b/packages/agent-loop/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/agent/package.json b/packages/agent/package.json index eb3a967338..3d2d421a75 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index c2b740741a..47e367a340 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/bash-local/package.json b/packages/bash-local/package.json index 7a8f6fb2a4..bc1dc7eb40 100644 --- a/packages/bash-local/package.json +++ b/packages/bash-local/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash-local/tsconfig.json b/packages/bash-local/tsconfig.json index 576ebe64a8..6a578833d6 100644 --- a/packages/bash-local/tsconfig.json +++ b/packages/bash-local/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/bash/package.json b/packages/bash/package.json index 8f33a4ccff..865de7b643 100644 --- a/packages/bash/package.json +++ b/packages/bash/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash/tsconfig.json b/packages/bash/tsconfig.json index f5803cec7f..e4c6cd4e12 100644 --- a/packages/bash/tsconfig.json +++ b/packages/bash/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/invariants/package.json b/packages/invariants/package.json index 20ffc74bbf..97d6160b03 100644 --- a/packages/invariants/package.json +++ b/packages/invariants/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/invariants/tsconfig.json b/packages/invariants/tsconfig.json index e87cca530d..784b64253b 100644 --- a/packages/invariants/tsconfig.json +++ b/packages/invariants/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/llm-deepseek/package.json b/packages/llm-deepseek/package.json index 0da7a35b5e..8ebf71c5b5 100644 --- a/packages/llm-deepseek/package.json +++ b/packages/llm-deepseek/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-deepseek/tsconfig.json b/packages/llm-deepseek/tsconfig.json index ceacbf1ee2..f6e5755202 100644 --- a/packages/llm-deepseek/tsconfig.json +++ b/packages/llm-deepseek/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/llm-pi-ai/package.json b/packages/llm-pi-ai/package.json index d212037a95..30911915ff 100644 --- a/packages/llm-pi-ai/package.json +++ b/packages/llm-pi-ai/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-pi-ai/tsconfig.json b/packages/llm-pi-ai/tsconfig.json index ceacbf1ee2..f6e5755202 100644 --- a/packages/llm-pi-ai/tsconfig.json +++ b/packages/llm-pi-ai/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/llm-replay/package.json b/packages/llm-replay/package.json index d9569c2d02..ce57ea18ef 100644 --- a/packages/llm-replay/package.json +++ b/packages/llm-replay/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm-replay/tsconfig.json b/packages/llm-replay/tsconfig.json index c2b740741a..47e367a340 100644 --- a/packages/llm-replay/tsconfig.json +++ b/packages/llm-replay/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/llm/package.json b/packages/llm/package.json index 6a01d52c6c..9e45e31f28 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json index f5803cec7f..e4c6cd4e12 100644 --- a/packages/llm/tsconfig.json +++ b/packages/llm/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/session-persistence-jsonl/package.json b/packages/session-persistence-jsonl/package.json index 8620858548..ac18a38838 100644 --- a/packages/session-persistence-jsonl/package.json +++ b/packages/session-persistence-jsonl/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence-jsonl/tsconfig.json b/packages/session-persistence-jsonl/tsconfig.json index 23465c380e..3209f6092d 100644 --- a/packages/session-persistence-jsonl/tsconfig.json +++ b/packages/session-persistence-jsonl/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/session-persistence-sqlite/package.json b/packages/session-persistence-sqlite/package.json index ad5cec37d6..b26c69461e 100644 --- a/packages/session-persistence-sqlite/package.json +++ b/packages/session-persistence-sqlite/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence-sqlite/tsconfig.json b/packages/session-persistence-sqlite/tsconfig.json index 23465c380e..3209f6092d 100644 --- a/packages/session-persistence-sqlite/tsconfig.json +++ b/packages/session-persistence-sqlite/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/session-persistence/package.json b/packages/session-persistence/package.json index 17b3c7a796..ed6c80dfd9 100644 --- a/packages/session-persistence/package.json +++ b/packages/session-persistence/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence/tsconfig.json b/packages/session-persistence/tsconfig.json index ebfd4b98f3..bfe2438963 100644 --- a/packages/session-persistence/tsconfig.json +++ b/packages/session-persistence/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/session/package.json b/packages/session/package.json index 42ef62567e..6136423ca2 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json index 747dd65daa..619f5e63cc 100644 --- a/packages/session/tsconfig.json +++ b/packages/session/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json index 7e419ed29a..672f7a03ef 100644 --- a/packages/system-prompt/package.json +++ b/packages/system-prompt/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json index 747dd65daa..619f5e63cc 100644 --- a/packages/system-prompt/tsconfig.json +++ b/packages/system-prompt/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/tool-bash/package.json b/packages/tool-bash/package.json index 23baf69058..f9092fabb6 100644 --- a/packages/tool-bash/package.json +++ b/packages/tool-bash/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/tool-bash/tsconfig.json b/packages/tool-bash/tsconfig.json index 131f52aca6..e47b47335c 100644 --- a/packages/tool-bash/tsconfig.json +++ b/packages/tool-bash/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/tools/package.json b/packages/tools/package.json index c78caf0f51..a6d3bbe0ca 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json index 20d6ab9643..c7a62d2fc3 100644 --- a/packages/tools/tsconfig.json +++ b/packages/tools/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/ui-stdio/package.json b/packages/ui-stdio/package.json index 8e7c54454f..5d65356e79 100644 --- a/packages/ui-stdio/package.json +++ b/packages/ui-stdio/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui-stdio/tsconfig.json b/packages/ui-stdio/tsconfig.json index f87b686386..2f209f4bcc 100644 --- a/packages/ui-stdio/tsconfig.json +++ b/packages/ui-stdio/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 263be4af5a..306e09b132 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -69,8 +69,8 @@ function workspaceManifests(): WorkspaceManifest[] { const dshPackageFiles = [ 'lib/index.js', - 'lib/typings/**/*.d.ts', - 'lib/typings/**/*.d.ts.map', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', 'src', ] as const @@ -108,11 +108,11 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.main !== 'lib/index.js') { errors.push(`${label}: package.json must set "main": "lib/index.js"`) } - if (manifest.types !== 'lib/typings/index.d.ts') { - errors.push(`${label}: package.json must set "types": "lib/typings/index.d.ts"`) + if (manifest.types !== 'lib/types/index.d.ts') { + errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`) } - if (manifest.exports?.['.']?.types !== './lib/typings/index.d.ts') { - errors.push(`${label}: package.json exports["."].types must be "./lib/typings/index.d.ts"`) + if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') { + errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`) } if (manifest.exports?.['.']?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) diff --git a/tsdown.config.ts b/tsdown.config.ts index d6c3cca603..13570868f2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown' /** * Runtime bundling for all workspace packages (vendor/* + packages/*). * TypeScript source is compiled first by `tsc -b tsconfig.build.json`; tsdown - * reads only the emitted JS under lib/typings and writes lib/index.* runtime + * reads only the emitted JS under lib/types and writes lib/index.* runtime * bundles. Declarations are NOT produced here, hence `dts: false`. * * Per-package shape overrides live in `/tsdown.config.ts` @@ -13,7 +13,7 @@ export default defineConfig({ // Explicit globs: `workspace: true` would also discover examples/* (any // package.json), but only vendor/* and packages/* are pnpm workspaces. workspace: ['vendor/*', 'packages/*'], - entry: ['lib/typings/index.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/README.md b/vendor/README.md index 43c53cfb53..dd55a9cd05 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -31,10 +31,10 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/typings/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/typings`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. -3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/typings`, and declare project references. +2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. +3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from explicit `.ts` / `.js` specifiers to extensionless specifiers so generated `.js` and `.d.ts` intermediates are extensionless and no declaration postprocess is needed. This includes `loader/src/config/isolate.ts` changing `declare module './entry.ts'` to `declare module './entry'`. -5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/typings` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. ## Sync procedure diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 59c3f69649..9d9ac07a34 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -6,11 +6,11 @@ "sideEffects": false, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "bin": "bin.js", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -18,8 +18,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "bin.js" ], "author": "Shigma ", diff --git a/vendor/cordis/tsconfig.json b/vendor/cordis/tsconfig.json index e0b2a46462..c7357481fd 100644 --- a/vendor/cordis/tsconfig.json +++ b/vendor/cordis/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noImplicitAny": false, "noImplicitThis": false, "strictFunctionTypes": false, diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index d313ce5477..940fcdb539 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/cosmokit/tsconfig.json b/vendor/cosmokit/tsconfig.json index eb79653390..b7411f94f2 100644 --- a/vendor/cosmokit/tsconfig.json +++ b/vendor/cosmokit/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/group/package.json b/vendor/group/package.json index d8d56c7675..34a8f59ae2 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/group/tsconfig.json b/vendor/group/tsconfig.json index 2d93e6ae42..e512d1d84c 100644 --- a/vendor/group/tsconfig.json +++ b/vendor/group/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 7bab5dd3d8..28087d5fa8 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/hmr/tsconfig.json b/vendor/hmr/tsconfig.json index cfa1f07afd..8464912787 100644 --- a/vendor/hmr/tsconfig.json +++ b/vendor/hmr/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/include/package.json b/vendor/include/package.json index 0c91733947..f9314d0c5e 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/include/tsconfig.json b/vendor/include/tsconfig.json index 056206ecab..6fe5099b43 100644 --- a/vendor/include/tsconfig.json +++ b/vendor/include/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 75b45a89a3..fde6d01d27 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/loader/tsconfig.json b/vendor/loader/tsconfig.json index ca6d75810a..2db62c7b63 100644 --- a/vendor/loader/tsconfig.json +++ b/vendor/loader/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index 33ec1d566a..8c0d8a0bda 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/shared.d.ts", + "types": "lib/types/shared.d.ts", "exports": { ".": { - "types": "./lib/typings/shared.d.ts", + "types": "./lib/types/shared.d.ts", "node": "./lib/index.js", "default": "./lib/browser.js" }, @@ -18,8 +18,8 @@ "files": [ "lib/index.js", "lib/browser.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/logger-console/tsconfig.json b/vendor/logger-console/tsconfig.json index 8714f410b6..cba4d151c7 100644 --- a/vendor/logger-console/tsconfig.json +++ b/vendor/logger-console/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/logger-console/tsdown.config.ts b/vendor/logger-console/tsdown.config.ts index c85dad4a28..0df6d4bd0b 100644 --- a/vendor/logger-console/tsdown.config.ts +++ b/vendor/logger-console/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown' /** * logger-console ships two entries: the node exporter (index) and the * browser exporter (browser), selected via package.json `exports` - * conditions. The entries are JS emitted by tsc under lib/typings and are + * conditions. The entries are JS emitted by tsc under lib/types and are * bundled as two single-entry passes so the shared base class is inlined into * each (matching upstream's published shape) instead of split into a hash-named * chunk. @@ -19,6 +19,6 @@ const shared = { } as const export default defineConfig([ - { ...shared, entry: ['lib/typings/index.js'] }, - { ...shared, entry: ['lib/typings/browser.js'] }, + { ...shared, entry: ['lib/types/index.js'] }, + { ...shared, entry: ['lib/types/browser.js'] }, ]) diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 8ce5cc5aff..ec5791f3af 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -5,12 +5,12 @@ "private": true, "main": "lib/index.cjs", "module": "lib/index.mjs", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "files": [ "lib/index.mjs", "lib/index.cjs", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/schemastery/tsconfig.json b/vendor/schemastery/tsconfig.json index f901861a39..b25fa05af7 100644 --- a/vendor/schemastery/tsconfig.json +++ b/vendor/schemastery/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "module": "preserve", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/schemastery/tsdown.config.ts b/vendor/schemastery/tsdown.config.ts index b16c217750..57f2f5f6c4 100644 --- a/vendor/schemastery/tsdown.config.ts +++ b/vendor/schemastery/tsdown.config.ts @@ -3,11 +3,11 @@ import { defineConfig } from 'tsdown' /** * schemastery has no `"type": "module"` and publishes dual-format output * (package.json: main → lib/index.cjs, module → lib/index.mjs). The entry is - * the JS emitted by tsc under lib/typings; pin the bundled extensions + * the JS emitted by tsc under lib/types; pin the bundled extensions * explicitly because the defaults for a CommonJS package would emit .mjs/.js. */ export default defineConfig({ - entry: ['lib/typings/index.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm', 'cjs'], platform: 'node', diff --git a/vendor/timer/package.json b/vendor/timer/package.json index ff68a84aa0..07c41150e8 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/typings/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/typings/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", @@ -16,8 +16,8 @@ }, "files": [ "lib/index.js", - "lib/typings/**/*.d.ts", - "lib/typings/**/*.d.ts.map", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/timer/tsconfig.json b/vendor/timer/tsconfig.json index fc4fc9f4fc..843303e870 100644 --- a/vendor/timer/tsconfig.json +++ b/vendor/timer/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/typings", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, From 1a81f2cccdd49df5c5a25e208b61c8681d8207d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:31:56 +0800 Subject: [PATCH 13/40] Add subagent capability seam: interface, mock backend, model-facing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `packages/subagent/` group and the abstract subagent seam — an agent delegating to a child agent — as a named-provider registry (`ctx.subagents`), unlike the single-implementation bash seam, so multiple transports (in-process, ACP, future A2A) coexist. This first PR lands the interface, a scripted test backend, and the model-facing tool, validated through the real cordis load path. - dsh-subagent: SubagentService registry + SubagentProvider/SubagentRun vocabulary + subagent/start|end events. Start-time capabilities (outputSchema, depthLimit, toolFilter) are checked pre-start and rejected loud; runtime capabilities (sendMessage, resume) are optional methods on SubagentRun. - dsh-subagent-mock (support): scripted provider for keyless, deterministic tests through the real Loader/export path. - dsh-tool-subagent: the model-facing `subagent` tool, config-bound to one provider; synchronous collect with try/finally dispose, signal->cancel bridging, and non-completed-stop-reason -> isError mapping. - Proposed RFC documenting the seam, the fork-vs-spawn-as-separate-backends decision, own-session isolation, synchronous-collect scope, and the deferral of background/poll/spill to a future unification with bash. - Wire the new group into tsconfigs, build refs, package hierarchy docs, the module graph, and the cordis catalog. RFC: docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md --- docs/cordis-catalog/events-and-services.md | 39 +++- docs/module-graph.md | 13 ++ docs/rfc/README.md | 1 + .../2026-06-21-subagent-capability-seam.md | 72 ++++++ packages/README.md | 7 + packages/subagent/README.md | 12 + packages/subagent/subagent/README.md | 39 ++++ packages/subagent/subagent/package.json | 34 +++ packages/subagent/subagent/src/index.ts | 191 ++++++++++++++++ packages/subagent/subagent/src/types.ts | 172 ++++++++++++++ .../subagent/subagent/tests/service.spec.ts | 182 +++++++++++++++ packages/subagent/subagent/tsconfig.json | 27 +++ packages/subagent/tool-subagent/README.md | 18 ++ packages/subagent/tool-subagent/package.json | 42 ++++ packages/subagent/tool-subagent/src/index.ts | 147 ++++++++++++ .../tool-subagent/tests/tool-subagent.spec.ts | 210 ++++++++++++++++++ packages/subagent/tool-subagent/tsconfig.json | 33 +++ packages/support/subagent-mock/README.md | 19 ++ packages/support/subagent-mock/package.json | 38 ++++ packages/support/subagent-mock/src/index.ts | 112 ++++++++++ .../subagent-mock/tests/subagent-mock.spec.ts | 95 ++++++++ packages/support/subagent-mock/tsconfig.json | 30 +++ pnpm-lock.yaml | 68 ++++++ tsconfig.base.json | 1 + tsconfig.build.json | 5 +- tsconfig.typecheck.json | 1 + 26 files changed, 1605 insertions(+), 3 deletions(-) create mode 100644 docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md create mode 100644 packages/subagent/README.md create mode 100644 packages/subagent/subagent/README.md create mode 100644 packages/subagent/subagent/package.json create mode 100644 packages/subagent/subagent/src/index.ts create mode 100644 packages/subagent/subagent/src/types.ts create mode 100644 packages/subagent/subagent/tests/service.spec.ts create mode 100644 packages/subagent/subagent/tsconfig.json create mode 100644 packages/subagent/tool-subagent/README.md create mode 100644 packages/subagent/tool-subagent/package.json create mode 100644 packages/subagent/tool-subagent/src/index.ts create mode 100644 packages/subagent/tool-subagent/tests/tool-subagent.spec.ts create mode 100644 packages/subagent/tool-subagent/tsconfig.json create mode 100644 packages/support/subagent-mock/README.md create mode 100644 packages/support/subagent-mock/package.json create mode 100644 packages/support/subagent-mock/src/index.ts create mode 100644 packages/support/subagent-mock/tests/subagent-mock.spec.ts create mode 100644 packages/support/subagent-mock/tsconfig.json diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 083d3295f6..8bb2b6b398 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 22 events across 5 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 6 scopes. ### `agent/*` @@ -231,6 +231,28 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts) +### `subagent/*` + +#### `subagent/end` — emit + +A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. + +```ts cordis-catalog +'subagent/end'(info: SubagentRunEndInfo): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:65`](../../packages/subagent/subagent/src/index.ts) + +#### `subagent/start` — emit + +A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. + +```ts cordis-catalog +'subagent/start'(info: SubagentRunInfo): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:59`](../../packages/subagent/subagent/src/index.ts) + ### `system-prompt/*` #### `system-prompt/assemble` — waterfall @@ -279,7 +301,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -392,6 +414,19 @@ list(): Session[] Source: [`packages/core/session/src/index.ts:229`](../../packages/core/session/src/index.ts) +### `ctx.subagents` — `SubagentService` + +The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. + +```ts cordis-catalog +registerProvider(provider: SubagentProvider): () => void +getProvider(name: string): SubagentProvider | undefined +list(): string[] +start(name: string, request: SubagentStartRequest): SubagentRun +``` + +Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) + ### `ctx.systemPrompt` — `SystemPrompt` Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..b8d582838f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -45,6 +45,9 @@ graph TD agent-loop --> session-persistence agent-loop --> system-prompt agent-loop --> tools + subagent --> agent + subagent --> llm + subagent --> tools tool-bash --> agent tool-bash --> bash tool-bash --> llm @@ -57,6 +60,13 @@ graph TD agent-core --> system-prompt agent-core --> tool-bash agent-core --> tools + subagent-mock --> agent + subagent-mock --> llm + subagent-mock --> subagent + tool-subagent --> agent + tool-subagent --> llm + tool-subagent --> subagent + tool-subagent --> tools acp-agent --> acp acp-agent --> agent-core acp-agent --> session-persistence-jsonl @@ -87,7 +97,10 @@ graph TD | `ui-stdio` | `agent`, `llm`, `session` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | +| `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `subagent-mock` | `agent`, `llm`, `subagent` | +| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 4eb7900276..41bb1c537f 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -44,6 +44,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | +| [Subagent capability seam](proposed/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md new file mode 100644 index 0000000000..501416b45a --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -0,0 +1,72 @@ +# RFC: Subagent capability seam + +Status: proposed + +> **Implementation status:** PR1 (this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer) is the first of three PRs. The two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`) and the out-of-process `dsh-subagent-acp` backend land in PR2 and PR3. Status stays `proposed` until all three ship; the file moves to `implemented/feature/` then, amended to describe what actually landed. + +## Problem + +The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent is sketched in two `TODO(sub-agents)` markers ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. No service, vocabulary, or implementation exists yet. + +The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: + +- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); +- **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); +- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. + +## Why not the bash seam shape + +The bash seam ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md)) registers exactly one `BashExecutor` per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a **named-provider registry** — each implementation registers under a unique name and a caller picks one by name — mirroring the **LLM adapter registry** (`LlmService.registerAdapter`), not the single-service bash executor. The seam is still three-package (interface / implementation / consumer); only the "one vs. many implementations" axis differs. + +## Proposal + +### The three-package seam + +A new package group `packages/subagent/`: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-subagent` | interface: `SubagentService` (`ctx.subagents`), `SubagentProvider`, `SubagentRun`, the request/result/capability vocabulary, the `subagent/*` events | +| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` (PR2) | +| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log (PR2) | +| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process (PR3) | +| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path (PR1) | +| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` (PR1) | + +### The primitive: `start → SubagentRun` + +A provider exposes `start(request) → SubagentRun`. The run carries a `result` promise (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and emits `subagent/start` / `subagent/end` around the run. + +### Two kinds of optional capability, discovered two ways + +- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. +- **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync. + +### Fork vs. fresh are separate backends, not a flag + +Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. + +### Child isolation and the parent log + +Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic. + +### Synchronous collect (first cut) + +The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. It does so inside a `try/finally` that always `dispose()`s the run (no leaked idle child/session on any path), bridges `exec.signal` to `run.cancel()`, and maps a non-`completed` stop reason to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but **intentionally unused** this cut. + +### Provider selection is config, not model-facing + +`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider. The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. + +## Plan (three PRs, each converged with Codex separately) + +1. **PR1 — interface + tool + mock.** This RFC, `dsh-subagent` (service, registry, vocabulary, `subagent/*` events), `dsh-subagent-mock` (scripted provider), `dsh-tool-subagent`. Wire the new `packages/subagent/` group into the tsconfigs, the build references, the package hierarchy docs, and the module graph. Tests: registry HMR-safety, duplicate-name rejection, start-time capability rejection, and at least one test driving the tool through the **real cordis Loader / export path** (a hand-built `ctx.plugin` mount bypasses `unwrapExports` and cannot catch a broken export shape — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). +2. **PR2 — in-process backends.** `dsh-subagent-spawn` and `dsh-subagent-fork` over `ctx.agents.create` + `AgentHandle.dispose`. The fork backend must seed only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix gives the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. Depth tracking (parent depth + 1, refused past `maxDepth`) and its exact storage are settled in PR2. +3. **PR3 — ACP backend.** `dsh-subagent-acp` as an ACP client over a configured spawn command (stdio); point it at our own `acp-agent` example to "talk to our own process". Minimal client stub: advertise no optional client capabilities, auto-resolve `session/request_permission` via a configured default, consume `session/update` without surfacing it this cut. Decide the `@agentclientprotocol/sdk` version (recommended: bump to 0.28.x for the fluent client API; the bump is shared with the existing `dsh-acp` bridge, so re-run its snapshot + e2e). + +## Risks and deferrals + +- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/execute` veto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. +- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). +- **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. +- **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. diff --git a/packages/README.md b/packages/README.md index f39d7b70a1..c2cba3461b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | +| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | @@ -37,6 +38,9 @@ dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks) dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge) dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) +dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam) +dsh-subagent-mock ← dsh-subagent (scripted provider for tests) +dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool) dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) @@ -69,6 +73,9 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | +| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | +| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | +| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `brand/` | `util` | Type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). diff --git a/packages/subagent/README.md b/packages/subagent/README.md new file mode 100644 index 0000000000..0fab2c610e --- /dev/null +++ b/packages/subagent/README.md @@ -0,0 +1,12 @@ +# subagent/ — subagent capability family + +The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry. + +| Package | Role | ctx key | +|---|---|---| +| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | + +The interface lives at `subagent/subagent/`. Provider implementations live in their own packages — the in-process `dsh-subagent-spawn` / `dsh-subagent-fork` and the out-of-process `dsh-subagent-acp` — plus the test-only `dsh-subagent-mock` in [support](../support/README.md). All **product** packages except the mock. + +The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md new file mode 100644 index 0000000000..de55315a79 --- /dev/null +++ b/packages/subagent/subagent/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-subagent + +The **subagent seam**: an abstract `SubagentService` (`ctx.subagents`) for an agent delegating work to another agent. A *subagent* is a child agent; a `SubagentProvider` is one transport for running it. + +This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-subagent` (this) | the interface: registry service + vocabulary types | +| `@deepseek-ai/dsh-subagent-spawn` | an implementation: fresh in-process child | +| `@deepseek-ai/dsh-subagent-fork` | an implementation: in-process child seeded from the parent's log | +| `@deepseek-ai/dsh-subagent-acp` | an implementation: ACP client driving another process | +| `@deepseek-ai/dsh-tool-subagent` | the model-facing tool over `ctx.subagents` | + +Unlike the bash seam (one executor per context, second load throws), **multiple providers coexist** here. Each registers under a unique name and a caller picks one by name — the shape mirrors the LLM adapter registry (`LlmService.registerAdapter`), not the single-service bash executor. This is the requirement that rules out the bash shape: an agent may want an in-process child for a cheap subtask and an out-of-process ACP child for an isolated one, in the same runtime. + +## Service API (`ctx.subagents`) + +| Member | Semantics | +|---|---| +| `registerProvider(provider)` | Register under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | +| `getProvider(name)` | Look up a provider (`undefined` if absent). | +| `list()` | Registered provider names (insertion order). | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. | + +## Capabilities: two kinds, discovered two ways + +- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. +- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. + +## Run lifecycle + +`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. + +## Scope (first cut) + +The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). + +See `src/types.ts` for the full contracts. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json new file mode 100644 index 0000000000..5e18f5e0ce --- /dev/null +++ b/packages/subagent/subagent/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-subagent", + "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts new file mode 100644 index 0000000000..a1aa2b2d0a --- /dev/null +++ b/packages/subagent/subagent/src/index.ts @@ -0,0 +1,191 @@ +/** + * The subagent seam (`ctx.subagents`): a named-provider registry plus a + * capability-validating `start` surface. A subagent is an agent delegating + * work to another agent; a {@link SubagentProvider} is one transport for + * running that child (in-process spawn/fork, ACP to another process, and — + * later — A2A, the Codex app-server, the Claude Code Agent SDK). + * + * Unlike the bash seam (one executor per context, second load throws), MULTIPLE + * providers coexist here: each registers under a unique name and a caller picks + * one by name. The shape mirrors the LLM adapter registry + * (`LlmService.registerAdapter`), not the single-service bash executor. + * + * This package is the INTERFACE third of the capability seam. Implementations + * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing + * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. + * + * Scope (first cut): the consumer collects synchronously — it starts a run and + * awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage}) + * is part of the contract but intentionally unused; background / poll / spill + * semantics are deferred to a future redesign that unifies long-running-tool + * handling across subagents and bash. + * + * @module @deepseek-ai/dsh-subagent + */ + +import { Context, Service } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { AgentId } from '@deepseek-ai/dsh-agent' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, +} from './types.ts' + +export type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, + SubagentStopReasonMap, +} from './types.ts' + +declare module 'cordis' { + interface Context { + subagents: SubagentService + } + + interface Events { + /** + * A subagent run started — emitted after the provider is resolved and its + * capabilities validated, as the child run begins. Paired with + * {@link Events['subagent/end']}. + * @mode emit + */ + 'subagent/start'(info: SubagentRunInfo): void + /** + * A subagent run settled — emitted when {@link SubagentRun.result} + * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * @mode emit + */ + 'subagent/end'(info: SubagentRunEndInfo): void + } +} + +/** Identifying detail for a started subagent run (the `subagent/start` payload). */ +export interface SubagentRunInfo { + /** The provider that started the run. */ + provider: string + /** The child agent/session id. */ + id: AgentId +} + +/** Outcome detail for a settled subagent run (the `subagent/end` payload). */ +export interface SubagentRunEndInfo { + /** The provider that ran it. */ + provider: string + /** The child agent/session id. */ + id: AgentId + /** The terminal stop reason. */ + stopReason: SubagentResult['stopReason'] +} + +/** + * Typed error for subagent-seam failures. Extends {@link HarnessError}, so the + * `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`) + * is shared, machine-routable taxonomy. + */ +export class SubagentError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentError' + } +} + +/** + * The `subagents` service: a registry of named {@link SubagentProvider}s and a + * capability-checked {@link start} surface. + */ +export class SubagentService extends Service { + private providers = new Map() + + constructor(ctx: Context) { + super(ctx, 'subagents') + } + + /** + * Register a provider under its `provider.name`. Throws {@link SubagentError} + * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed + * with the calling fiber (HMR-safe). + */ + registerProvider(provider: SubagentProvider): () => void { + const dispose = this.ctx.effect(function* (this: SubagentService) { + if (this.providers.has(provider.name)) { + throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') + } + this.providers.set(provider.name, provider) + yield () => { + this.providers.delete(provider.name) + } + }.bind(this), 'subagents.registerProvider()') + // ctx.effect's disposer returns Promise; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** Look up a registered provider by name (`undefined` if absent). */ + getProvider(name: string): SubagentProvider | undefined { + return this.providers.get(name) + } + + /** The names of all registered providers (insertion order). */ + list(): string[] { + return [...this.providers.keys()] + } + + /** + * Start a subagent run on the named provider. Resolves the provider (throws + * `NO_PROVIDER` if absent), validates every requested START-TIME capability + * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` + * for the first unmet one — fail loud, before any child is created), then + * delegates to {@link SubagentProvider.start} and emits `subagent/start` / + * `subagent/end` around the run. + */ + start(name: string, request: SubagentStartRequest): SubagentRun { + const provider = this.providers.get(name) + if (!provider) { + throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') + } + this.assertCapabilities(provider, request) + + const run = provider.start(request) + this.ctx.emit('subagent/start', { provider: name, id: run.id }) + // Emit `subagent/end` when the run settles. The result promise does not + // reject on a child-level failure (it resolves with stopReason 'error'), + // so a rejection here is an infrastructure fault — surface its stop reason + // as 'error' for the telemetry event without swallowing the rejection + // (the consumer still observes it via `run.result`). + void run.result.then( + (result) => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, + () => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, + ) + return run + } + + /** + * Reject a request that needs a start-time capability the provider lacks. + * Each optional request field maps to one {@link SubagentCapabilities} flag; + * the first unmet one throws `UNSUPPORTED_CAPABILITY`. + */ + private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void { + const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [ + { when: request.outputSchema !== undefined, cap: 'outputSchema' }, + { when: request.maxDepth !== undefined, cap: 'depthLimit' }, + { when: request.toolFilter !== undefined, cap: 'toolFilter' }, + ] + for (const { when, cap } of needs) { + if (when && !provider.capabilities[cap]) { + throw new SubagentError( + `subagent provider "${provider.name}" does not support the "${cap}" capability`, + 'UNSUPPORTED_CAPABILITY', + ) + } + } + } +} + +export default SubagentService diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts new file mode 100644 index 0000000000..0e04acb317 --- /dev/null +++ b/packages/subagent/subagent/src/types.ts @@ -0,0 +1,172 @@ +/** + * Subagent seam vocabulary: the request/result/capability types a + * {@link SubagentProvider} consumes and produces. No runtime code — types + * only, per the package convention. + * + * @module @deepseek-ai/dsh-subagent/types + */ + +import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SchemaSpec } from '@deepseek-ai/dsh-tools' + +/** + * Which START-TIME features a provider supports. Checked by the service + * BEFORE delegating to {@link SubagentProvider.start}: a request that needs a + * capability the chosen provider lacks is rejected with a typed error rather + * than accepted-then-ignored (the "fail loud, no silent degradation" rule). + * + * Start-time features live here (a static descriptor) because they must be + * checked before a run exists. RUNTIME features (steering, resume) are instead + * modeled as OPTIONAL METHODS on {@link SubagentRun}: the method's presence IS + * the capability, and TS narrowing is the discovery mechanism — a consumer + * cannot call an absent method without narrowing first. + */ +export interface SubagentCapabilities { + /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ + outputSchema: boolean + /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ + depthLimit: boolean + /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ + toolFilter: boolean +} + +/** + * What a caller asks for when starting a subagent. The tool layer builds this + * from the model's `{ description, prompt }` plus its own config; the service + * validates {@link SubagentCapabilities} against the named provider, then + * passes it to {@link SubagentProvider.start}. + */ +export interface SubagentStartRequest { + /** The task/prompt for the child agent (a user message in the child session). */ + prompt: ContentBlock[] + /** + * The spawning ("parent") agent — the one whose tool call started this + * subagent. REQUIRED: in-process backends read `parent.session.header` for + * the working directory, the `parentSession` lineage to stamp on the child, + * and the parent's delegation depth. Out-of-process backends (ACP) ignore it. + */ + parent: Agent + /** + * Cancellation signal from the spawning context (the tool's `exec.signal`). + * A provider that honors it aborts the child when the signal fires; the + * consumer also bridges it to {@link SubagentRun.cancel} explicitly. + */ + signal?: AbortSignal + /** Per-child agent options (model, system prompt). */ + agentOptions?: AgentOptions + /** + * Optional structured-output schema. When set AND the provider's + * {@link SubagentCapabilities.outputSchema} is `true`, the child's final + * answer is shaped to this schema and surfaced as {@link SubagentResult.structured}. + * Requesting it against a provider that lacks the capability is rejected at start. + */ + outputSchema?: SchemaSpec + /** + * Optional recursion cap (max delegation depth below this child). Requires + * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise. + */ + maxDepth?: number + /** + * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; + * rejected at start otherwise. + */ + toolFilter?: { allow?: string[]; deny?: string[] } +} + +/** + * Why a subagent run ended. Merge-extensible (a backend may add variants); + * consumers branch on the known cases and fall through `default`. The known + * cases mirror the harness turn-end vocabulary so the tool layer can map a + * non-`completed` result to an `isError` tool result. + */ +export interface SubagentStopReasonMap { + /** The child finished its turn normally. */ + completed: 'completed' + /** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */ + aborted: 'aborted' + /** The child failed (model error, transport error). */ + error: 'error' + /** The child hit its token ceiling before finishing. */ + 'max-tokens': 'max-tokens' + /** The child declined the task. */ + refusal: 'refusal' +} + +export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap] + +/** + * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. + */ +export interface SubagentResult { + /** The child's final assistant output (the last assistant message's content). */ + output: ContentBlock[] + /** + * The structured result, present IFF the request carried an `outputSchema` + * AND the provider honored it. Shape is validated against the request schema + * by the provider; `unknown` here because the seam is schema-agnostic. + */ + structured?: unknown + /** Why the run ended. A non-`completed` reason means `output` may be partial. */ + stopReason: SubagentStopReason +} + +/** + * A live subagent run: a handle the consumer holds while a child executes. + * Returned by {@link SubagentProvider.start} (via the service). The consumer + * awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose} + * on every path to reach child quiescence (no leaked idle child / session). + * + * {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports + * the runtime capability defines the method; one that doesn't omits it. The + * presence of the method IS the capability — narrow before calling. + */ +export interface SubagentRun { + /** The child agent's id (also its session id token, for correlation). */ + readonly id: AgentId + /** + * Resolves with the child's terminal {@link SubagentResult} when the run + * settles. Does NOT reject on a child-level failure — a model/transport + * failure resolves with `stopReason: 'error'` so the consumer maps it to an + * `isError` tool result. Rejects only on an infrastructure fault the seam + * cannot represent as a stop reason. + */ + readonly result: Promise + /** Request cancellation of the in-flight run; {@link result} settles `aborted`. */ + cancel(reason?: string): void + /** + * Reach child quiescence and release the run's resources (in-process: dispose + * the owned agent handle and remove its session; ACP: kill the subprocess). + * Idempotent; awaits the child actually stopping, not merely requesting it. + */ + dispose(): Promise + /** + * OPTIONAL (steering capability): send additional content to the running + * child between steps. Present only on providers that support live steering. + */ + sendMessage?(content: ContentBlock[]): void + /** + * OPTIONAL (resume capability): send a follow-up task to a settled child, + * continuing its session, and return a fresh run for the continuation. + */ + resume?(content: ContentBlock[]): SubagentRun +} + +/** + * A subagent backend: one transport for running a child agent (in-process + * spawn/fork, ACP to another process, …). Implementations register under a + * unique name via {@link SubagentService.registerProvider}; multiple providers + * coexist in one context (unlike the single-implementation bash seam). + */ +export interface SubagentProvider { + /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ + readonly name: string + /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ + readonly capabilities: SubagentCapabilities + /** + * Start a child run. The service has already validated that every requested + * start-time capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. + */ + start(request: SubagentStartRequest): SubagentRun +} diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts new file mode 100644 index 0000000000..bdccaecaab --- /dev/null +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import SubagentService, { + SubagentError, + type SubagentCapabilities, + type SubagentProvider, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, +} from '@deepseek-ai/dsh-subagent' + +/** A minimal parent Agent stand-in — the service only reads `parent.id`. */ +function fakeParent(id = 'parent-1'): Agent { + return { id: AgentId(id) } as unknown as Agent +} + +const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } +const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + +/** A scripted provider whose run settles immediately with a fixed result. */ +class StubProvider implements SubagentProvider { + startCount = 0 + constructor( + readonly name: string, + readonly capabilities: SubagentCapabilities = ALL_CAPS, + private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' }, + ) {} + + start(request: SubagentStartRequest): SubagentRun { + this.startCount++ + return { + id: AgentId(`child:${this.name}:${request.parent.id}`), + result: Promise.resolve(this.result), + cancel() {}, + async dispose() {}, + } + } +} + +function baseRequest(overrides: Partial = {}): SubagentStartRequest { + return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides } +} + +describe('SubagentService', () => { + it('registers a provider and starts a run on it by name', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('alpha') + ctx.subagents.registerProvider(provider) + + expect(ctx.subagents.list()).toEqual(['alpha']) + expect(ctx.subagents.getProvider('alpha')).toBe(provider) + + const run = ctx.subagents.start('alpha', baseRequest()) + expect(provider.startCount).toBe(1) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + }) + + it('lets multiple providers coexist (the defining requirement)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('spawn')) + ctx.subagents.registerProvider(new StubProvider('acp')) + + expect(ctx.subagents.list()).toEqual(['spawn', 'acp']) + expect(ctx.subagents.getProvider('spawn')).toBeDefined() + expect(ctx.subagents.getProvider('acp')).toBeDefined() + }) + + it('throws NO_PROVIDER when starting on an unregistered name', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + try { + ctx.subagents.start('missing', baseRequest()) + expect.fail('expected NO_PROVIDER') + } catch (error: unknown) { + expect(error).toBeInstanceOf(SubagentError) + expect((error as SubagentError).code).toBe('NO_PROVIDER') + } + }) + + it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('dup')) + try { + ctx.subagents.registerProvider(new StubProvider('dup')) + expect.fail('expected DUPLICATE_PROVIDER') + } catch (error: unknown) { + expect(error).toBeInstanceOf(SubagentError) + expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER') + } + }) + + it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.subagents.registerProvider(new StubProvider('scoped')) + }, { inject: ['subagents'] })) + expect(ctx.subagents.list()).toEqual(['scoped']) + + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('re-registers a name after its prior registration is disposed (not wedged)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + + const dispose = ctx.subagents.registerProvider(new StubProvider('reuse')) + expect(ctx.subagents.list()).toEqual(['reuse']) + dispose() + expect(ctx.subagents.list()).toEqual([]) + + const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse')) + expect(ctx.subagents.list()).toEqual(['reuse']) + disposeAgain() + expect(ctx.subagents.list()).toEqual([]) + }) + + describe('start-time capability validation (fail loud, before any child)', () => { + it.each([ + { field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) }, + { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) }, + { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) }, + ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => { + const ctx = new Context() + return ctx.plugin(SubagentService).then(() => { + const provider = new StubProvider('weak', NO_CAPS) + ctx.subagents.registerProvider(provider) + try { + ctx.subagents.start('weak', request) + expect.fail('expected UNSUPPORTED_CAPABILITY') + } catch (error: unknown) { + expect(error).toBeInstanceOf(SubagentError) + expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY') + } + // The child was never started — the check is pre-spawn. + expect(provider.startCount).toBe(0) + }) + }) + + it('allows a capability request when the provider supports it', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('strong', ALL_CAPS) + ctx.subagents.registerProvider(provider) + ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 })) + expect(provider.startCount).toBe(1) + }) + }) + + it('emits subagent/start then subagent/end around a run', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('events')) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('events', baseRequest()) + expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id })) + + await run.result + // `subagent/end` fires from a `.then` on the result — let the microtask run. + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) + }) + + it('SubagentError extends the shared HarnessError base', () => { + const err = new SubagentError('boom', 'NO_PROVIDER') + expect(err).toBeInstanceOf(HarnessError) + expect(err.name).toBe('SubagentError') + expect(err.code).toBe('NO_PROVIDER') + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json new file mode 100644 index 0000000000..eed656aa31 --- /dev/null +++ b/packages/subagent/subagent/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md new file mode 100644 index 0000000000..b53fe9ee5f --- /dev/null +++ b/packages/subagent/tool-subagent/README.md @@ -0,0 +1,18 @@ +# @deepseek-ai/dsh-tool-subagent + +The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees. + +## Provider selection is config, not model-facing + +This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider. Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. + +| Config key | Meaning | +|---|---| +| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | +| `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | + +## Lifecycle (synchronous collect) + +`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success. + +Background / poll collection is deferred (see the [RFC](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes. diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json new file mode 100644 index 0000000000..a7960db9bb --- /dev/null +++ b/packages/subagent/tool-subagent/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-tool-subagent", + "description": "Model-facing subagent delegation tool over the ctx.subagents seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-mock": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts new file mode 100644 index 0000000000..44ed22c7ec --- /dev/null +++ b/packages/subagent/tool-subagent/src/index.ts @@ -0,0 +1,147 @@ +/** + * The model-facing `subagent` tool: delegate a task to a child agent and return + * its final output. Pure schema + lifecycle shaping — every transport concern + * lives behind the `ctx.subagents` provider registry + * (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend + * swaps in without touching what the model sees. + * + * Provider selection is config, not model-facing: this plugin is bound to + * EXACTLY ONE provider name (`Config.provider`). To expose more than one + * transport, load the plugin more than once, each bound to a different provider + * — there is no provider/type parameter in the model-facing schema. The model + * sees only `{ description, prompt }`. + * + * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits + * `run.result` inside a `try/finally` that always disposes the run, so the + * owned child agent/session is torn down on every path (success, error, abort) + * and never leaks as a live idle child. A non-`completed` stop reason maps to an + * `isError` tool result (by throwing) rather than returning partial output as + * success. + * + * @module @deepseek-ai/dsh-tool-subagent + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { AgentOptions } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' + +export const name = 'tool-subagent' +export const inject = ['tools', 'subagents'] + +/** Config: which registered provider this tool delegates to, plus child defaults. */ +export interface Config { + /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ + provider: string + /** + * Default per-child agent options (model, system prompt) applied to every + * spawned child. Omitted fields fall back to the child loop's own defaults. + */ + agentOptions?: AgentOptions +} + +export const Config: z = z.object({ + provider: z.string().required(), + agentOptions: z.object({ + model: z.string(), + systemPrompt: z.string(), + }), +}) + +/** + * Flatten a child's final output blocks to text for the tool result. The child + * may return non-text blocks; this cut surfaces the text content (the common + * case) and drops the rest, which is acceptable for a synchronous summary — + * the structured path (`outputSchema`) is the channel for non-text results. + */ +function outputText(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is Extract => b.type === 'text') + .map(b => b.text) + .join('') +} + +/** A non-`completed` stop reason means the child did not finish cleanly. */ +function stopReasonError(result: SubagentResult): string | undefined { + switch (result.stopReason) { + case 'completed': + return undefined + case 'aborted': + return 'subagent run was cancelled' + case 'error': + return 'subagent run failed' + case 'max-tokens': + return 'subagent run hit its token limit before finishing' + case 'refusal': + return 'subagent declined the task' + // Merge-extensible union: a backend may add stop reasons. Treat an unknown + // terminal reason as a failure rather than reporting partial output as success. + default: + return `subagent run ended abnormally (${String(result.stopReason)})` + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.tools.register(defineTool({ + name: 'subagent', + description: + 'Delegate a self-contained task to a subagent (a separate agent that works in its own context) ' + + 'and return its final result. Use this to offload focused, independent work — research, a scoped ' + + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent ' + + 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a ' + + 'complete, standalone prompt: it does not see this conversation.', + parameters: { + description: { + type: 'string', + required: true, + description: 'A short (3-5 word) description of the delegated task, for display.', + }, + prompt: { + type: 'string', + required: true, + description: 'The complete, self-contained task for the subagent. It does not share this ' + + 'conversation\'s context, so include everything it needs.', + }, + }, + async execute(args, exec): Promise { + const parent = exec.agent + if (!parent) { + // The loop sets `exec.agent` for every model-driven call; its absence + // means a non-agent caller invoked the tool directly, which has no + // parent to attribute the child to. Fail loud rather than guess. + throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') + } + + const request: SubagentStartRequest = { + prompt: [{ type: 'text', text: args.prompt }], + parent, + ...exec.signal ? { signal: exec.signal } : {}, + ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + } + + const run: SubagentRun = ctx.subagents.start(config.provider, request) + + // Bridge the tool's abort signal to the run: if the parent step is + // aborted while the child is in flight, cancel the child too. + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + + try { + const result = await run.result + const error = stopReasonError(result) + if (error !== undefined) { + // Map a non-clean finish to an isError result (the registry turns a + // throw into an isError). Report the reason, not partial output. + throw new Error(error) + } + return [{ type: 'text', text: outputText(result.output) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + // Always reach child quiescence — never leak a live idle child/session. + await run.dispose() + } + }, + })) +} diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts new file mode 100644 index 0000000000..a5e0f26d7f --- /dev/null +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as mock from '@deepseek-ai/dsh-subagent-mock' +import * as tool from '../src/index.ts' + +/** + * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real + * `ToolRegistry` + `SubagentService`, with the real `dsh-subagent-mock` as the + * backend, and invokes the registered `subagent` tool through + * `ctx.tools.execute`. The mock is the genuine collaborator (we mock only the + * "child agent", the expensive/non-deterministic boundary) — everything + * downstream of the tool is the shipping code path. + */ + +/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ +function fakeAgent(id = 'parent-1'): Agent { + return { id: AgentId(id) } as unknown as Agent +} + +async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'mock', ...mockConfig }) + await ctx.plugin(tool, toolConfig) + return ctx +} + +let callCounter = 0 +function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) { + // Distinguish "no override" (use a default agent) from an explicit + // `{ agent: undefined }` (test the no-agent path). Under + // exactOptionalPropertyTypes the key is omitted rather than set to undefined. + const agent = 'agent' in over ? over.agent : fakeAgent() + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name: 'subagent', + arguments: args, + ...agent ? { agent } : {}, + ...over.signal ? { signal: over.signal } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('dsh-tool-subagent', () => { + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { + const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) + const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('child says hi') + }) + + it('exposes only description + prompt to the model (no provider/type parameter)', async () => { + const ctx = await setup({ provider: 'mock' }) + const schema = ctx.tools.schemas().find(s => s.name === 'subagent') + expect(schema).toBeDefined() + const props = (schema!.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual(['description', 'prompt']) + }) + + it('maps a non-completed stop reason to an isError result (not partial success)', async () => { + const ctx = await setup({ provider: 'mock' }, { stopReason: 'refusal' }) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('declined') + }) + + it('fails loud when invoked without a calling agent', async () => { + const ctx = await setup({ provider: 'mock' }) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: undefined }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('requires a calling agent') + }) + + it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here ' + + '(the tool requests no capabilities) — a missing provider IS surfaced', async () => { + // Bind the tool to a provider name that is not registered: the service throws + // NO_PROVIDER, the registry turns it into an isError result. + const ctx = await setup({ provider: 'does-not-exist' }) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no subagent provider') + }) + + it('disposes the run on the success path (no leaked child)', async () => { + // Spy on the provider's run.dispose via a wrapping provider registered + // directly on the service, then point the tool at it. + const disposed = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('spy-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => void disposed(), + }), + }) + await ctx.plugin(tool, { provider: 'spy' }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(disposed).toHaveBeenCalledTimes(1) + }) + + it('disposes the run on the error path too', async () => { + const disposed = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('spy-child'), + result: Promise.resolve({ output: [], stopReason: 'error' as const }), + cancel() {}, + dispose: async () => void disposed(), + }), + }) + await ctx.plugin(tool, { provider: 'spy' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(disposed).toHaveBeenCalledTimes(1) + }) + + it('bridges the tool abort signal to run.cancel()', async () => { + const cancelled = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => { + let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void + const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) + return { + id: AgentId('spy-child'), + result, + cancel: () => { + cancelled() + resolveResult({ output: [], stopReason: 'aborted' }) + }, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'spy' }) + + const controller = new AbortController() + const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) + controller.abort() + const result = await pending + expect(cancelled).toHaveBeenCalledTimes(1) + expect(result.isError).toBe(true) + }) + + it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + // No SubagentService mounted. The tool injects ['tools','subagents'] so its + // apply never runs; the tool is absent rather than half-registered. + let booted = true + try { + await ctx.plugin(tool, { provider: 'mock' }) + await new Promise(r => setTimeout(r, 20)) + } catch { + booted = false + } + // Either it never booted, or it booted but registered no tool. + const present = ctx.get('tools')?.schemas().some(s => s.name === 'subagent') ?? false + expect(booted && present).toBe(false) + }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { + // Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so + // a stray `export default apply` would collapse the module via + // `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at + // load with "cannot get property … without inject". Guard the shape directly. + expect('default' in tool).toBe(false) + expect(tool.name).toBe('tool-subagent') + expect(tool.inject).toEqual(['tools', 'subagents']) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(tool) as Record + expect(unwrapped).toBe(tool) + expect(unwrapped.name).toBe('tool-subagent') + expect(unwrapped.inject).toEqual(['tools', 'subagents']) + expect(typeof unwrapped.apply).toBe('function') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json new file mode 100644 index 0000000000..896580883f --- /dev/null +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md new file mode 100644 index 0000000000..305aea93c4 --- /dev/null +++ b/packages/support/subagent-mock/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-subagent-mock + +A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md). + +It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the **real cordis Loader / export path**, exercising provider registration, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all deterministically and keylessly. + +## Usage + +Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no default). Config (all optional): + +| Key | Default | Meaning | +|---|---|---| +| `name` | `mock` | Registry name to register the provider under. | +| `reply` | `mock subagent reply` | The scripted child's final answer text. | +| `stopReason` | `completed` | The stop reason `result` settles with. | +| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. | +| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | + +A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json new file mode 100644 index 0000000000..ccb4eeb32f --- /dev/null +++ b/packages/support/subagent-mock/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-subagent-mock", + "description": "Scripted subagent provider for testing the subagent seam (keyless, deterministic)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts new file mode 100644 index 0000000000..c1a988fbfe --- /dev/null +++ b/packages/support/subagent-mock/src/index.ts @@ -0,0 +1,112 @@ +/** + * A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a + * model or a real child agent. Mirrors `@deepseek-ai/dsh-llm-replay`: it lets a + * test drive the service and the model-facing tool through the REAL cordis + * Loader / export path, exercising registration, capability validation, the + * run lifecycle, and the structured-output branch deterministically. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default — + * a functional plugin (it only registers a provider; it is never injected). + * + * @module @deepseek-ai/dsh-subagent-mock + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { AgentId } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' + +const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const + +const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } + +/** + * A scripted provider: every {@link start} returns a run whose `result` + * resolves on a microtask with the configured reply (and a structured value + * when the request asked for one and the capability is on). `dispose` is a + * no-op; a `cancel()` before the result settles flips the stop reason to + * `aborted`, so the cancellation path is observable in a test. + */ +class MockSubagentProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities + + constructor( + readonly name: string, + private readonly config: Config, + ) { + this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities } + } + + start(request: SubagentStartRequest): SubagentRun { + const reply = this.config.reply ?? 'mock subagent reply' + const output: ContentBlock[] = [{ type: 'text', text: reply }] + const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema + const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed' + let cancelled = false + + // A deterministic child id derived from the parent — no clock/random (both + // banned in deterministic paths here, and unnecessary for a scripted run). + const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`) + + const resultFor = (): SubagentResult => ({ + output, + structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined, + stopReason: cancelled ? 'aborted' : baseStop, + }) + + return { + id, + result: Promise.resolve().then(resultFor), + cancel() { + cancelled = true + }, + async dispose() { + // Scripted run holds no resources — nothing to await. + }, + } + } +} + +export const name = 'subagent-mock' +export const inject = ['subagents'] + +/** Config for the mock provider; all optional with test-friendly defaults. */ +export interface Config { + /** Registry name to register under. */ + name: string + /** The text the scripted child "returns" as its final answer. */ + reply?: string + /** The stop reason the run settles with. */ + stopReason?: SubagentStopReason + /** Which start-time capabilities to advertise (default: all `true`). */ + capabilities?: Partial + /** + * Structured value surfaced when a request carries an `outputSchema` and the + * `outputSchema` capability is on (default: `{ reply }`). + */ + structured?: unknown +} + +export const Config: z = z.object({ + name: z.string().default('mock'), + reply: z.string(), + stopReason: z.union(STOP_REASONS), + capabilities: z.object({ + outputSchema: z.boolean(), + depthLimit: z.boolean(), + toolFilter: z.boolean(), + }), + structured: z.any(), +}) + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new MockSubagentProvider(config.name, config)) +} diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts new file mode 100644 index 0000000000..f5a567289d --- /dev/null +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import * as mock from '../src/index.ts' + +/** A minimal parent — the mock provider only reads `parent.id`. */ +function fakeParent(id = 'parent-1'): Agent { + return { id: AgentId(id) } as unknown as Agent +} + +function baseRequest(over: Partial = {}): SubagentStartRequest { + return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over } +} + +async function mount(config: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'mock', ...config }) + return ctx +} + +describe('dsh-subagent-mock', () => { + it('registers a provider on ctx.subagents and returns the scripted reply', async () => { + const ctx = await mount({ reply: 'hello from mock' }) + expect(ctx.subagents.list()).toEqual(['mock']) + + const run = ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'hello from mock' }], + structured: undefined, + stopReason: 'completed', + }) + }) + + it('registers under a configurable name', async () => { + const ctx = await mount({ name: 'spawn' }) + expect(ctx.subagents.list()).toEqual(['spawn']) + }) + + it('surfaces a structured result when the request carries an outputSchema', async () => { + const ctx = await mount({ reply: 'r', structured: { answer: 42 } }) + const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } })) + await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) + }) + + it('omits structured output when outputSchema capability is off', async () => { + const ctx = await mount({ capabilities: { outputSchema: false } }) + // The service rejects an outputSchema request against a no-cap provider, so + // the structured path is only reachable when the cap is on; with it off and + // no schema requested, the result has no structured field. + const run = ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toMatchObject({ structured: undefined }) + }) + + it('honors a configured stop reason', async () => { + const ctx = await mount({ stopReason: 'refusal' }) + const run = ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' }) + }) + + it('flips the stop reason to aborted when cancelled before the result settles', async () => { + const ctx = await mount() + const run = ctx.subagents.start('mock', baseRequest()) + run.cancel() + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + }) + + it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(mock, { name: 'mock' }) + expect(ctx.subagents.list()).toEqual(['mock']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { + // Postmortem 0001 guard: this plugin HAS `inject = ['subagents']`, so a stray + // `export default apply` would collapse the module via `unwrapExports` + // (`exports.default ?? exports`), DROP `inject`, and crash at load with + // "cannot get property … without inject". Guard the shape directly. + expect('default' in mock).toBe(false) + expect(mock.name).toBe('subagent-mock') + expect(mock.inject).toEqual(['subagents']) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(mock) as Record + expect(unwrapped).toBe(mock) + expect(unwrapped.name).toBe('subagent-mock') + expect(unwrapped.inject).toEqual(['subagents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/support/subagent-mock/tsconfig.json b/packages/support/subagent-mock/tsconfig.json new file mode 100644 index 0000000000..fd44cbde3a --- /dev/null +++ b/packages/support/subagent-mock/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../subagent/subagent" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..4d56ed4e19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,52 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/tool-subagent: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-mock': + specifier: workspace:^ + version: link:../../support/subagent-mock + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/invariants: devDependencies: '@deepseek-ai/dsh-agent': @@ -345,6 +391,28 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/subagent-mock: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/ui-stdio: dependencies: schemastery: diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..38091c6142 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,6 +43,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/subagent/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..44f984a784 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -31,6 +31,9 @@ { "path": "./packages/ui/acp-agent" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/ui-stdio" }, - { "path": "./packages/support/llm-replay" } + { "path": "./packages/support/llm-replay" }, + { "path": "./packages/subagent/subagent" }, + { "path": "./packages/support/subagent-mock" }, + { "path": "./packages/subagent/tool-subagent" } ] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 54769bdbc0..ac9191b34b 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -20,6 +20,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/subagent/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", From 25eccdaedcfc918a32a30592219871a807f1e8a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:15:43 +0800 Subject: [PATCH 14/40] Fix review findings: lifecycle containment, configurable tool name, coverage, type catalog Address four findings from the first Codex review round: - Contain subagent/start|end listener throws (emitContainedStart/End): a thrown lifecycle listener could escape SubagentService.start() before the caller received the live run to dispose it (a leaked child), and a thrown subagent/end listener could surface as an unhandled rejection on the detached result-settle hook. Both emits now log-and-contain, mirroring the agent registry's agent/created|disposed containment. - Make the model-facing tool name configurable (Config.toolName, default subagent). The docs say to load dsh-tool-subagent once per provider to expose multiple transports, but the hardcoded name made the second load throw a duplicate-tool-name error; a distinct toolName per load is now required and documented. - Reach the per-file 100% coverage gate: tests for the subagent/end error branch, lifecycle-listener containment, every stopReasonError arm + the merge-extensible default, the multi-provider toolName path, agentOptions forwarding, and the direct-apply schema-bypass fallbacks. - Document the seam vocabulary in docs/core-data-structures/subagent.md with verbatim type-equiv blocks + manifest entries, and link it from core.md (a brand-new core/seam type the doc-sync gate cannot detect on its own). --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/subagent.md | 88 +++++++++++++ .../2026-06-21-subagent-capability-seam.md | 2 +- packages/subagent/subagent/src/index.ts | 40 +++++- .../subagent/subagent/tests/service.spec.ts | 54 ++++++++ packages/subagent/tool-subagent/README.md | 3 +- packages/subagent/tool-subagent/src/index.ts | 11 +- .../tool-subagent/tests/tool-subagent.spec.ts | 116 +++++++++++++++++- .../subagent-mock/tests/subagent-mock.spec.ts | 6 + scripts/type-equiv.manifest.json | 9 +- 10 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 docs/core-data-structures/subagent.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b50c3483e4..117d1e3606 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | +| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md new file mode 100644 index 0000000000..d9fdbfa215 --- /dev/null +++ b/docs/core-data-structures/subagent.md @@ -0,0 +1,88 @@ +# Subagent + +The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. + +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent RFC](../rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). + +Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) + +## Two kinds of capability, discovered two ways + +A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism. + +```ts type-equiv +interface SubagentCapabilities { + outputSchema: boolean + depthLimit: boolean + toolFilter: boolean +} +``` + +## The start request + +What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. + +```ts type-equiv +interface SubagentStartRequest { + prompt: ContentBlock[] + parent: Agent + signal?: AbortSignal + agentOptions?: AgentOptions + outputSchema?: SchemaSpec + maxDepth?: number + toolFilter?: { allow?: string[]; deny?: string[] } +} +``` + +## The terminal result: `SubagentResult` + +The outcome of a run, resolved by `SubagentRun.result`. `structured` is present iff the request carried an `outputSchema` AND the provider honored it. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. + +```ts type-equiv +interface SubagentResult { + output: ContentBlock[] + structured?: unknown + stopReason: SubagentStopReason +} +``` + +`SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure: + +```ts type-equiv +interface SubagentStopReasonMap { + completed: 'completed' + aborted: 'aborted' + error: 'error' + 'max-tokens': 'max-tokens' + refusal: 'refusal' +} +``` + +## A live run: `SubagentRun` + +The handle the consumer holds while a child executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. + +```ts type-equiv +interface SubagentRun { + readonly id: AgentId + readonly result: Promise + cancel(reason?: string): void + dispose(): Promise + sendMessage?(content: ContentBlock[]): void + resume?(content: ContentBlock[]): SubagentRun +} +``` + +## The provider seam: `SubagentProvider` + +One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. + +```ts type-equiv +interface SubagentProvider { + readonly name: string + readonly capabilities: SubagentCapabilities + start(request: SubagentStartRequest): SubagentRun +} +``` + +The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener (logged, never propagated) so one bad subscriber can neither strand a live run nor surface as an unhandled rejection on the detached settle hook. diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index 501416b45a..553077d35d 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -56,7 +56,7 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin ### Provider selection is config, not model-facing -`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider. The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. +`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. ## Plan (three PRs, each converged with Codex separately) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index a1aa2b2d0a..cf42746fef 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -153,19 +153,51 @@ export class SubagentService extends Service { this.assertCapabilities(provider, request) const run = provider.start(request) - this.ctx.emit('subagent/start', { provider: name, id: run.id }) + // CONTAIN lifecycle-listener throws: the run is already live, so a throwing + // `subagent/start` listener must NOT escape `start()` (the caller would + // never receive the run to dispose it — a leaked child). Emit defensively + // and log a thrown listener, mirroring the agent registry's `agent/created` + // /`agent/disposed` containment. + this.emitContainedStart({ provider: name, id: run.id }) // Emit `subagent/end` when the run settles. The result promise does not // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). + // (the consumer still observes it via `run.result`). Containment also keeps + // a thrown `subagent/end` listener from becoming an unhandled rejection on + // this detached `.then`. void run.result.then( - (result) => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, - () => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, + (result) => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: result.stopReason }) }, + () => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: 'error' }) }, ) return run } + /** + * Emit `subagent/start`, containing a thrown listener (log, never propagate) + * so one bad subscriber cannot strand the already-live run before the caller + * receives it to dispose. + */ + private emitContainedStart(info: SubagentRunInfo): void { + try { + this.ctx.emit('subagent/start', info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: subagent/start listener threw: ${String(error)}`) + } + } + + /** + * Emit `subagent/end`, containing a thrown listener so it cannot surface as an + * unhandled rejection on the detached result-settle hook. + */ + private emitContainedEnd(info: SubagentRunEndInfo): void { + try { + this.ctx.emit('subagent/end', info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: subagent/end listener threw: ${String(error)}`) + } + } + /** * Reject a request that needs a start-time capability the provider lacks. * Each optional request field maps to one {@link SubagentCapabilities} flag; diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index bdccaecaab..be743a7a3f 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,60 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + // A provider whose run.result REJECTS (an infrastructure fault — the seam + // contract says child-level failures resolve with stopReason 'error', but a + // rejection is still surfaced as an 'error' telemetry event). + ctx.subagents.registerProvider({ + name: 'rejecter', + capabilities: NO_CAPS, + start: () => ({ + id: AgentId('rej-child'), + result: Promise.reject(new Error('infra fault')), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('rejecter', baseRequest()) + // Observe (and swallow) the rejection the consumer would see, then let the + // detached `.then` settle the telemetry emit. + await run.result.catch(() => {}) + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' })) + }) + + it('contains a throwing subagent/start listener so start() still returns the run', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('contain')) + // A bad subscriber must not strand the live run: start() returns it anyway. + ctx.on('subagent/start', () => { throw new Error('bad start listener') }) + + const run = ctx.subagents.start('contain', baseRequest()) + expect(run.id).toBeDefined() + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + }) + + it('contains a throwing subagent/end listener (no unhandled rejection on the settle hook)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('contain-end')) + ctx.on('subagent/end', () => { throw new Error('bad end listener') }) + + const run = ctx.subagents.start('contain-end', baseRequest()) + await run.result + // Let the detached `.then` + the contained emit run; a thrown listener here + // must be swallowed (logged), not escape as an unhandled rejection. + await Promise.resolve() + await Promise.resolve() + expect(run.id).toBeDefined() + }) + it('SubagentError extends the shared HarnessError base', () => { const err = new SubagentError('boom', 'NO_PROVIDER') expect(err).toBeInstanceOf(HarnessError) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index b53fe9ee5f..22b66fbf80 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -4,11 +4,12 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen ## Provider selection is config, not model-facing -This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider. Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. +This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. | Config key | Meaning | |---|---| | `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | +| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | | `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 44ed22c7ec..a19e09db98 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -35,6 +35,14 @@ export const inject = ['tools', 'subagents'] export interface Config { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string + /** + * The model-facing tool name to register (default `subagent`). To expose more + * than one transport, load this plugin once per provider — each load MUST set + * a distinct `toolName` (the tool registry rejects a duplicate name), e.g. + * `{ provider: 'spawn', toolName: 'subagent' }` and + * `{ provider: 'acp', toolName: 'subagent_acp' }`. + */ + toolName?: string /** * Default per-child agent options (model, system prompt) applied to every * spawned child. Omitted fields fall back to the child loop's own defaults. @@ -44,6 +52,7 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), + toolName: z.string().default('subagent'), agentOptions: z.object({ model: z.string(), systemPrompt: z.string(), @@ -85,7 +94,7 @@ function stopReasonError(result: SubagentResult): string | undefined { export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ - name: 'subagent', + name: config.toolName ?? 'subagent', description: 'Delegate a self-contained task to a subagent (a separate agent that works in its own context) ' + 'and return its final result. Use this to offload focused, independent work — research, a scoped ' diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index a5e0f26d7f..521fdfba50 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -68,11 +68,121 @@ describe('dsh-tool-subagent', () => { expect(Object.keys(props).sort()).toEqual(['description', 'prompt']) }) - it('maps a non-completed stop reason to an isError result (not partial success)', async () => { - const ctx = await setup({ provider: 'mock' }, { stopReason: 'refusal' }) + it.each([ + { stopReason: 'aborted' as const, fragment: 'cancelled' }, + { stopReason: 'error' as const, fragment: 'failed' }, + { stopReason: 'max-tokens' as const, fragment: 'token limit' }, + { stopReason: 'refusal' as const, fragment: 'declined' }, + ])('maps stop reason $stopReason to an isError result (not partial success)', async ({ stopReason, fragment }) => { + const ctx = await setup({ provider: 'mock' }, { stopReason }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) - expect(text(result)).toContain('declined') + expect(text(result)).toContain(fragment) + }) + + it('registers under a configurable toolName so multiple providers can coexist', async () => { + // The defining multi-provider use case: two loads, two distinct tool names, + // each bound to a different provider — the tool registry rejects duplicate + // names, so a configurable name is what makes this work. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'spawn', reply: 'from spawn' }) + await ctx.plugin(mock, { name: 'acp', reply: 'from acp' }) + await ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' }) + await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' }) + + const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort() + expect(names).toEqual(['subagent', 'subagent_acp']) + + const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + expect(text(viaSpawn)).toBe('from spawn') + expect(text(viaAcp)).toBe('from acp') + }) + + it('treats an unknown (plugin-added) stop reason as an isError result', async () => { + // SubagentStopReason is merge-extensible; the tool's stopReasonError default + // arm must treat an unrecognized terminal reason as a failure, not success. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'weird', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('weird-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), + cancel() {}, + dispose: async () => {}, + }), + }) + await ctx.plugin(tool, { provider: 'weird' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('abnormally') + }) + + it('forwards configured agentOptions into the start request', async () => { + // Cover the `config.agentOptions ? … : {}` spread: a provider that captures + // the request lets us assert the agentOptions reached it. + let seen: { agentOptions?: { model?: string } } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('capture-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) + }) + + it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { + // `ctx.plugin` validates+defaults config first (toolName→'subagent', the + // agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the + // no-agentOptions branch are only reachable via a direct apply() that + // bypasses schemastery — the same pattern acp-agent uses for its defaults. + let seen: { agentOptions?: unknown } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'bare', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('bare-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + // Direct apply with only `provider` — no toolName, no agentOptions. + tool.apply(ctx, { provider: 'bare' }) + await new Promise(r => setTimeout(r, 10)) + + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.agentOptions).toBeUndefined() }) it('fails loud when invoked without a calling agent', async () => { diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index f5a567289d..f35ed884eb 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -45,6 +45,12 @@ describe('dsh-subagent-mock', () => { await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) }) + it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => { + const ctx = await mount({ reply: 'fallback reply' }) + const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } })) + await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) + }) + it('omits structured output when outputSchema capability is off', async () => { const ctx = await mount({ capabilities: { outputSchema: false } }) // The service rejects an outputSchema request against a no-cap provider, so diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f46c4fca6b..7872ec8620 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -35,6 +35,13 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" } + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" } ] } From 861791d2d8fdebed536985e2dec015b92792653f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:44:07 +0800 Subject: [PATCH 15/40] Contain subagent lifecycle listeners per-listener, not per-emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single try/catch around ctx.emit prevented a thrown subagent/start or subagent/end listener from propagating, but cordis emit dispatches listeners in a `.map(cb => cb())` that HALTS on the first throw — so a bad subscriber still starved the listeners registered after it, violating the AGENTS.md callback-boundary rule ("one bad subscriber must not starve the listeners after it"). Resolve the listener callbacks via ctx.events.dispatch and contain each call individually, the same per-listener guarantee BashExecutor.notifyTaskDone gives its own listener set. The two containment tests now register TWO listeners where the first throws and assert the second still observes the event (start) and the settle (end) — a regression that fails on the per-emit code (verified: reverted, watched both go red, restored). --- docs/core-data-structures/subagent.md | 2 +- packages/subagent/subagent/src/index.ts | 63 ++++++++++--------- .../subagent/subagent/tests/service.spec.ts | 18 ++++-- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index d9fdbfa215..5ae83b40fa 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -85,4 +85,4 @@ interface SubagentProvider { } ``` -The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener (logged, never propagated) so one bad subscriber can neither strand a live run nor surface as an unhandled rejection on the detached settle hook. +The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index cf42746fef..c7d954f09c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -153,48 +153,49 @@ export class SubagentService extends Service { this.assertCapabilities(provider, request) const run = provider.start(request) - // CONTAIN lifecycle-listener throws: the run is already live, so a throwing - // `subagent/start` listener must NOT escape `start()` (the caller would - // never receive the run to dispose it — a leaked child). Emit defensively - // and log a thrown listener, mirroring the agent registry's `agent/created` - // /`agent/disposed` containment. - this.emitContainedStart({ provider: name, id: run.id }) + // Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}): + // the run is already live, so neither a throwing subscriber escaping + // `start()` (the caller would never receive the run to dispose it — a leaked + // child) NOR one bad subscriber starving the listeners after it is + // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single + // surrounding try/catch is not enough — each listener is invoked and + // contained individually. + this.emitLifecycle('subagent/start', { provider: name, id: run.id }) // Emit `subagent/end` when the run settles. The result promise does not // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). Containment also keeps - // a thrown `subagent/end` listener from becoming an unhandled rejection on - // this detached `.then`. + // (the consumer still observes it via `run.result`). Per-listener + // containment also keeps a thrown `subagent/end` listener from becoming an + // unhandled rejection on this detached `.then`. void run.result.then( - (result) => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: result.stopReason }) }, - () => { this.emitContainedEnd({ provider: name, id: run.id, stopReason: 'error' }) }, + (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, ) return run } /** - * Emit `subagent/start`, containing a thrown listener (log, never propagate) - * so one bad subscriber cannot strand the already-live run before the caller - * receives it to dispose. + * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch + * each subscriber individually and log (never propagate) a thrown one, so one + * bad subscriber can neither strand the already-live run, surface as an + * unhandled rejection on the detached settle hook, NOR starve the listeners + * registered after it. A single try/catch around `ctx.emit` would not do the + * last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts + * on the first throw — so this resolves the listener callbacks via + * `ctx.events.dispatch` and contains each call, the same guarantee + * `BashExecutor.notifyTaskDone` gives its own listener set. */ - private emitContainedStart(info: SubagentRunInfo): void { - try { - this.ctx.emit('subagent/start', info) - } catch (error: unknown) { - this.ctx.logger.warn(`subagent: subagent/start listener threw: ${String(error)}`) - } - } - - /** - * Emit `subagent/end`, containing a thrown listener so it cannot surface as an - * unhandled rejection on the detached result-settle hook. - */ - private emitContainedEnd(info: SubagentRunEndInfo): void { - try { - this.ctx.emit('subagent/end', info) - } catch (error: unknown) { - this.ctx.logger.warn(`subagent: subagent/end listener threw: ${String(error)}`) + private emitLifecycle( + name: 'subagent/start' | 'subagent/end', + info: SubagentRunInfo | SubagentRunEndInfo, + ): void { + for (const callback of this.ctx.events.dispatch('emit', [name, info])) { + try { + callback(info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`) + } } } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index be743a7a3f..6876c6cd80 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -200,31 +200,37 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' })) }) - it('contains a throwing subagent/start listener so start() still returns the run', async () => { + it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => { const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider('contain')) - // A bad subscriber must not strand the live run: start() returns it anyway. + // Two listeners; the FIRST throws. Per-listener containment means the second + // must STILL run (a single try/catch around ctx.emit would let the first + // throw halt the dispatch and starve the second — the round-2 regression). + const second = vi.fn() ctx.on('subagent/start', () => { throw new Error('bad start listener') }) + ctx.on('subagent/start', second) const run = ctx.subagents.start('contain', baseRequest()) expect(run.id).toBeDefined() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id })) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) }) - it('contains a throwing subagent/end listener (no unhandled rejection on the settle hook)', async () => { + it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => { const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider('contain-end')) + const second = vi.fn() ctx.on('subagent/end', () => { throw new Error('bad end listener') }) + ctx.on('subagent/end', second) const run = ctx.subagents.start('contain-end', baseRequest()) await run.result - // Let the detached `.then` + the contained emit run; a thrown listener here - // must be swallowed (logged), not escape as an unhandled rejection. + // Let the detached `.then` + the contained emit run. await Promise.resolve() await Promise.resolve() - expect(run.id).toBeDefined() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' })) }) it('SubagentError extends the shared HarnessError base', () => { From fd55d205484dee44b1d279d7551f096797f79e4c Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 21 Jun 2026 23:57:00 +0800 Subject: [PATCH 16/40] revert: remove the non-branch changes introduced during the rebase --- AGENTS.md | 3 +-- docs/development.md | 1 - .../2026-06-11-doc-sync-enforcement.md | 2 -- pnpm-lock.yaml | 20 ++----------------- 4 files changed, 3 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9631fed4d..f901b6d702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,7 @@ pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/archit # matches the interface Events declarations in source pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/README.md, AGENTS.md (one line per paragraph) -pnpm run verify-md-links # assert relative Markdown links resolve in checked docs -pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs diff --git a/docs/development.md b/docs/development.md index 83d1cb3fac..5a14a23aa5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,7 +95,6 @@ pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown -pnpm run verify-md-links # fail on broken relative Markdown links in checked docs pnpm run doc-sync # doc-typecheck, event taxonomy, markdown wrap, and link verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale diff --git a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md index 51aff694b8..7918f5762c 100644 --- a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md @@ -19,8 +19,6 @@ Both run via a shared `doc-sync` package.json script that the lefthook pre-push **Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the AGENTS.md "Markdown is not hard-wrapped" convention. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. -**Amendment (2026-06-18):** a fourth gate, **`verify-md-links`**, was later folded into `doc-sync` by the [Markdown cross-link validity linting RFC](2026-06-18-markdown-cross-link-lint.md). It checks that every relative Markdown link in the checked docs resolves to an existing file, so the RFC tree can use date-based filenames and relative links instead of stale numeric prose references. `doc-sync` is now four gates. - ## Consequences - Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d44608e415..5026104cd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 0.3.21 tsdown: specifier: ^0.22.2 - version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1) + version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -2620,16 +2620,6 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - unrun@0.3.1: - resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} - engines: {node: ^22.13.0 || >=24.0.0} - hasBin: true - peerDependencies: - synckit: ^0.11.11 - peerDependenciesMeta: - synckit: - optional: true - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4943,7 +4933,7 @@ snapshots: optionalDependencies: typescript: 6.0.3 - tsdown@0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1): + tsdown@0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -4964,7 +4954,6 @@ snapshots: publint: 0.3.21 tsx: 4.22.4 typescript: 6.0.3 - unrun: 0.3.1 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -5026,11 +5015,6 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unrun@0.3.1: - dependencies: - rolldown: 1.1.1 - optional: true - uri-js@4.4.1: dependencies: punycode: 2.3.1 From 88b75181adcd0a5278f8a4d771b125da49ece628 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 22 Jun 2026 00:51:41 +0800 Subject: [PATCH 17/40] fix: apply ts-build-config adjustment to new packages --- package.json | 2 +- packages/bash/bash/src/index.ts | 2 +- packages/core/agent-core/package.json | 8 ++-- .../core/agent-core/tests/agent-core.spec.ts | 2 +- packages/core/agent-core/tsconfig.json | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 2 +- .../agent/tests/gen-cordis-catalog.spec.ts | 2 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- packages/llm/llm/src/assembler.ts | 4 +- .../session-persistence/src/coordinator.ts | 2 +- .../session-persistence/src/index.ts | 4 +- .../tests/coordinator-contract.ts | 4 +- packages/ui/acp-agent/package.json | 11 +++-- packages/ui/acp-agent/tests/acp-agent.spec.ts | 2 +- packages/ui/acp-agent/tsconfig.json | 2 +- packages/ui/acp-agent/tsdown.config.ts | 7 ++-- packages/ui/stdio-agent/package.json | 11 +++-- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 2 +- packages/ui/stdio-agent/tsconfig.json | 2 +- packages/ui/stdio-agent/tsdown.config.ts | 7 ++-- packages/util/brand/package.json | 8 ++-- packages/util/brand/tsconfig.json | 2 +- tsconfig.json | 40 ++++++++++--------- 26 files changed, 76 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index 05ca0cecad..1e6155ff94 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", - "clean:build": "rm -rf .typecheck packages/*/lib vendor/*/lib *.tsbuildinfo", + "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", "typecheck": "tsc -b tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index e7f4fad421..b8d7c619e1 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -17,7 +17,7 @@ import { Context, Service } from 'cordis' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types' -export { BashTaskId, OwnerToken } from './types.ts' +export { BashTaskId, OwnerToken } from './types' export type { BashExecRequest, BashExecSpec, diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index d6e716835b..a70ee30e71 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 67f5d88532..fe3d89eca6 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import * as agentCore from '../src/index.ts' +import * as agentCore from '../src/index' import { AgentId } from '@deepseek-ai/dsh-agent' /** diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json index 3cf1e3fb74..83bf06c586 100644 --- a/packages/core/agent-core/tsconfig.json +++ b/packages/core/agent-core/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 9cdaa1973b..71b1b80ea4 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -18,7 +18,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse } from './mock-adapter' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index ee2ce47699..e040b39b77 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -14,7 +14,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts' +import { collectEvents } from '../../../../scripts/gen-cordis-catalog' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index b01b498dff..51fbdbd187 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -4,7 +4,7 @@ import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' -import { assemble, type AssembledResult } from './assemble.ts' +import { assemble, type AssembledResult } from './assemble' /** * Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1abbebc060..f576831e2c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import LlmService, { LlmError } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' -import { assemble } from './assemble.ts' +import { assemble } from './assemble' /** One scripted behavior for the next request the mock server receives. */ type Behavior = diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index fa30226ddf..678bb409fd 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -5,7 +5,7 @@ import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { Config } from '@deepseek-ai/dsh-llm-pi-ai' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { assemble, type AssembledResult } from './assemble.ts' +import { assemble, type AssembledResult } from './assemble' /** * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro across all diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 63f9f90456..bd09afff99 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' -import { assemble } from './assemble.ts' +import { assemble } from './assemble' /** Scripted SSE responses, one per request (OpenAI chat-completions shape). */ interface MockServer { diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 65402738d8..fd13c34ad1 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-llm/assembler */ -import { CallId } from './brand.ts' -import { assertNever } from './never.ts' +import { CallId } from './brand' +import { assertNever } from './never' import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types' interface PartialBlock { diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 7c7b044ee1..ace8125ce2 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -27,7 +27,7 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix } from './index.ts' +import { assertSerializable, seedCoversPrefix } from './index' /** * A stored session's durable prefix as read back from a backend: its diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index a9ffd11792..239feb2825 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -29,8 +29,8 @@ import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-se export type { SessionHeader } from '@deepseek-ai/dsh-session' // The backend-agnostic write-path orchestration first-party backends compose. -export { PersistenceCoordinator } from './coordinator.ts' -export type { PersistenceBackend, StoredPrefix } from './coordinator.ts' +export { PersistenceCoordinator } from './coordinator' +export type { PersistenceBackend, StoredPrefix } from './coordinator' declare module 'cordis' { interface Context { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 431d02b4cb..7eab088deb 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -30,8 +30,8 @@ import { describe, expect, it } from 'vitest' import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '../src/index.ts' -import { meta, oneTurnLog } from './contract.ts' +import type { SessionPersistence } from '../src/index' +import { meta, oneTurnLog } from './contract' /** * The backend-specific capabilities the orchestration suite needs beyond the diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 0ecbba9e6a..72eb95b2f7 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -5,24 +5,27 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "bin": { "dsh-acp-agent": "lib/bin.js" }, "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./bin": { - "types": "./lib/bin.d.ts", + "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 7a02837fca..c8e7920669 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import * as acpAgent from '../src/index.ts' +import * as acpAgent from '../src/index' /** * In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition: diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index 773ca2e293..ffea8ec6f6 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/ui/acp-agent/tsdown.config.ts b/packages/ui/acp-agent/tsdown.config.ts index a0710d6e4d..9dd130b30d 100644 --- a/packages/ui/acp-agent/tsdown.config.ts +++ b/packages/ui/acp-agent/tsdown.config.ts @@ -3,11 +3,12 @@ import { defineConfig } from 'tsdown' /** * acp-agent ships TWO entries: the plugin (`index`) and the CLI `bin` (`bin`), * the latter referenced by package.json `bin`/`exports["./bin"]`. The root - * tsdown builds only `src/index.ts`, so this override adds `bin.ts`. - * Declarations come from `tsc -b` (dts: false), matching every package. + * tsdown builds only `lib/types/index.js`, so this override adds + * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), + * matching every package. */ export default defineConfig({ - entry: ['src/index.ts', 'src/bin.ts'], + entry: ['lib/types/index.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index 45e8021607..bc9c98a411 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -5,24 +5,27 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "bin": { "dsh-stdio-agent": "lib/bin.js" }, "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./bin": { - "types": "./lib/bin.d.ts", + "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index f72de0a1da..a5dc1fbf90 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { AgentId } from '@deepseek-ai/dsh-agent' -import * as stdioAgent from '../src/index.ts' +import * as stdioAgent from '../src/index' /** * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 2130a6162c..58b492a549 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/ui/stdio-agent/tsdown.config.ts b/packages/ui/stdio-agent/tsdown.config.ts index 62dc986c08..53797cdd79 100644 --- a/packages/ui/stdio-agent/tsdown.config.ts +++ b/packages/ui/stdio-agent/tsdown.config.ts @@ -3,11 +3,12 @@ import { defineConfig } from 'tsdown' /** * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin` * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. - * The root tsdown builds only `src/index.ts`, so this override adds `bin.ts`. - * Declarations come from `tsc -b` (dts: false), matching every package. + * The root tsdown builds only `lib/types/index.js`, so this override adds + * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), + * matching every package. */ export default defineConfig({ - entry: ['src/index.ts', 'src/bin.ts'], + entry: ['lib/types/index.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index f0dcf7a8d7..8059952170 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json index f8fc535ab7..749cb0208e 100644 --- a/packages/util/brand/tsconfig.json +++ b/packages/util/brand/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/tsconfig.json b/tsconfig.json index d3adb723af..a4c1a8245c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,23 +20,27 @@ { "path": "./vendor/timer" }, { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, - { "path": "./packages/llm" }, - { "path": "./packages/session" }, - { "path": "./packages/session-persistence" }, - { "path": "./packages/session-persistence-jsonl" }, - { "path": "./packages/session-persistence-sqlite" }, - { "path": "./packages/system-prompt" }, - { "path": "./packages/agent" }, - { "path": "./packages/tools" }, - { "path": "./packages/agent-loop" }, - { "path": "./packages/bash" }, - { "path": "./packages/llm-deepseek" }, - { "path": "./packages/llm-pi-ai" }, - { "path": "./packages/bash-local" }, - { "path": "./packages/tool-bash" }, - { "path": "./packages/invariants" }, - { "path": "./packages/acp" }, - { "path": "./packages/ui-stdio" }, - { "path": "./packages/llm-replay" } + { "path": "./packages/util/brand" }, + { "path": "./packages/llm/llm" }, + { "path": "./packages/core/session" }, + { "path": "./packages/session-persistence/session-persistence" }, + { "path": "./packages/session-persistence/session-persistence-jsonl" }, + { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/core/system-prompt" }, + { "path": "./packages/core/agent" }, + { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-loop" }, + { "path": "./packages/core/agent-core" }, + { "path": "./packages/bash/bash" }, + { "path": "./packages/llm/llm-deepseek" }, + { "path": "./packages/llm/llm-pi-ai" }, + { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/support/invariants" }, + { "path": "./packages/ui/acp" }, + { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/stdio-agent" }, + { "path": "./packages/support/ui-stdio" }, + { "path": "./packages/support/llm-replay" } ] } From 732e121ff6c7ac63ec23ab6149a9d9b1312bf017 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 22 Jun 2026 01:01:08 +0800 Subject: [PATCH 18/40] fix: make constraints, lint and md-links happy --- docs/cookbook/adding-a-package.md | 2 +- docs/rfc/README.md | 2 +- .../2026-06-17-ts-build-config.md | 0 eslint.config.mjs | 2 +- scripts/check-workspace-constraints.ts | 26 +++++++++++++++---- 5 files changed, 24 insertions(+), 8 deletions(-) rename docs/rfc/implemented/{ => process}/2026-06-17-ts-build-config.md (100%) diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index b52761baf1..991db3be60 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -15,7 +15,7 @@ packages// README.md # service API, events, extension points, design notes ``` -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. ## 2. Register it in the root configs diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 08a5dae8f6..f46cb3495f 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -125,7 +125,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 | | [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | | [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 | -| [TSC-first build and one tsconfig](implemented/2026-06-17-ts-build-config.md) | 2026-06-17 | +| [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 | | [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 | | [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | diff --git a/docs/rfc/implemented/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md similarity index 100% rename from docs/rfc/implemented/2026-06-17-ts-build-config.md rename to docs/rfc/implemented/process/2026-06-17-ts-build-config.md diff --git a/eslint.config.mjs b/eslint.config.mjs index df7570c89e..52236e5d64 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,7 +36,7 @@ export default tseslint.config( ], languageOptions: { parserOptions: { - project: ['./packages/*/tsconfig.json', './tsconfig.json'], + project: ['./packages/*/*/tsconfig.json', './tsconfig.json'], tsconfigRootDir: import.meta.dirname, }, }, diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 940de45f81..a669a8572d 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -35,12 +35,15 @@ interface PackageManifest { type?: string main?: string types?: string - exports?: { - '.'?: { + bin?: string | Record + exports?: Record< + string, + | { types?: string default?: string } - } + | undefined + > files?: string[] peerDependencies?: Record devDependencies?: Record @@ -89,10 +92,22 @@ const dshPackageFiles = [ 'src', ] as const +const dshBinPackageFiles = [ + 'lib/index.js', + 'lib/bin.js', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', +] as const + function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean { return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index]) } +function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { + return manifest.bin ? dshBinPackageFiles : dshPackageFiles +} + function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { const errors: string[] = [] const label = manifest.name ?? dir @@ -132,8 +147,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.exports?.['.']?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) } - if (!sameStringList(manifest.files, dshPackageFiles)) { - errors.push(`${label}: package.json files must be ${JSON.stringify(dshPackageFiles)}`) + const expectedFiles = expectedDshPackageFiles(manifest) + if (!sameStringList(manifest.files, expectedFiles)) { + errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) } } From 94e7355449b96f2858f0c788cd2dd11ff57b1a5e Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 22 Jun 2026 01:24:36 +0800 Subject: [PATCH 19/40] docs: update packages hierarchy to current rfc --- .../rfc/implemented/process/2026-06-17-ts-build-config.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md index 6c5a11156f..f0a52d8d6a 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -8,7 +8,7 @@ Status: implemented (accepted 2026-06-20) The current TypeScript build and typecheck setup had these issues: -- `build` used `tsc` to transform `.ts` to `.d.ts` files for `packages/*` and `vendor/*`, and then used `tsdown` to transform `.ts` to bundled `.js` files. This made two tools do TypeScript transform. +- `build` used `tsc` to transform `.ts` to `.d.ts` files for packages under `packages//` and `vendor/*`, and then used `tsdown` to transform `.ts` to bundled `.js` files. This made two tools do TypeScript transform. - `typecheck` tended to validate packages, vendor source, examples, tests, and scripts through one root typecheck config. The goal is to make build and typecheck use matching tsconfig boundaries and TypeScript resolution/transform behavior. Build should generate `.js`, `.d.ts`, `.js.map`, and `.d.ts.map` through one compiler and config, so publish output and type validation stay consistent. @@ -21,7 +21,7 @@ Validation found several concrete technical issues and possible routes: - Bundled `.js` emitted by `tsdown` is not the same behavior as per-file `.js` emitted by `tsc -b`, such as decorator transform behavior. - `vendor/*/src`, examples, tests, and scripts cannot all be plain-included in one root strict program. - Directly typechecking `vendor/*/src` under the root strict config triggers many type errors outside this project's ownership. - - `package/*` dependencies on `vendor` are resolved to the `vendor/*/lib` for different tsconfig strictness. + - Package dependencies under `packages/*/*` on `vendor` are resolved to the `vendor/*/lib` for different tsconfig strictness. ## Decision @@ -38,7 +38,7 @@ In-package relative imports are extensionless. `pnpm run typecheck` runs build mode over the root `tsconfig.json`. - The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/tsconfig.json` or `vendor/*/tsconfig.json`. +- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. The command orchestration shape is: @@ -57,7 +57,7 @@ tsc -b tsconfig.json Build responsibilities are clearer: -- Each module under `packages/*` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. +- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. - The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. From 7aabd2a3dfb673f756781fd7879394682952d82f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:58:40 +0800 Subject: [PATCH 20/40] Add in-process subagent backends: spawn (fresh) and fork (seeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second PR of the subagent seam: the two in-process backends that run a child agent on the same cordis context, reusing the agent factory's quiescent AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider registry) and share one run driver. - dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the parent's model by default (overridable), zero inherited conversation. Also exports the shared in-process run driver (startInProcessRun): mint ids, stamp cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read the last assistant/message + turn/end reason, dispose to quiescence. - dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn prefix (the log up to and including its last turn/end), so the child inherits context. The in-flight unbalanced turn is excluded — a raw seed would fail the invariants replay. Proven: a regression test goes red if the boundary seeds the open turn. - Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent → ctx.sessions.prepare({ seed }) (the primitive resume already used). This is the fork-lineage path the TODO(sub-agents) markers anticipated. - Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for a child); the depthLimit capability refuses a spawn past request.maxDepth. Tests: real-loop unit tests for both backends (mock MODEL only, real loop + invariants), a multi-subagent test (one parent drives a fork AND a spawn child then keeps working), and a with-key e2e (a real parent delegates via the `subagent` tool to a real child that writes a file on disk — world-verified). 100% per-file coverage. The coding-agent demo wires the spawn backend + tool. Snapshot coverage of nested agents is deferred to a stacked follow-up (TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor that cannot route calls to a parent vs. a child on one context. Recorded in the RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its test infrastructure END TO END up front, verifying the snapshot/e2e harness can express the new shape — a gap this plan hit. --- AGENTS.md | 1 + docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/subagent.md | 7 + docs/module-graph.md | 10 + .../2026-06-21-subagent-capability-seam.md | 1 + examples/coding-agent/cordis.yml | 33 ++- knip.json | 4 + packages/README.md | 4 + packages/core/agent-loop/src/index.ts | 14 +- packages/core/agent/src/index.ts | 13 +- packages/subagent/README.md | 4 +- packages/subagent/subagent-fork/README.md | 23 ++ packages/subagent/subagent-fork/package.json | 45 ++++ packages/subagent/subagent-fork/src/index.ts | 79 ++++++ .../tests/multi-subagent.spec.ts | 99 ++++++++ .../subagent-fork/tests/subagent-fork.spec.ts | 161 ++++++++++++ packages/subagent/subagent-fork/tsconfig.json | 33 +++ packages/subagent/subagent-spawn/README.md | 29 +++ packages/subagent/subagent-spawn/package.json | 48 ++++ .../subagent/subagent-spawn/src/in-process.ts | 164 ++++++++++++ packages/subagent/subagent-spawn/src/index.ts | 57 +++++ .../subagent/subagent-spawn/tests/harness.ts | 49 ++++ .../subagent-spawn/tests/spawn.e2e.ts | 54 ++++ .../tests/subagent-spawn.spec.ts | 239 ++++++++++++++++++ .../subagent/subagent-spawn/tsconfig.json | 33 +++ packages/support/llm-replay/src/index.ts | 10 + pnpm-lock.yaml | 89 +++++++ tsconfig.build.json | 4 +- 28 files changed, 1296 insertions(+), 13 deletions(-) create mode 100644 packages/subagent/subagent-fork/README.md create mode 100644 packages/subagent/subagent-fork/package.json create mode 100644 packages/subagent/subagent-fork/src/index.ts create mode 100644 packages/subagent/subagent-fork/tests/multi-subagent.spec.ts create mode 100644 packages/subagent/subagent-fork/tests/subagent-fork.spec.ts create mode 100644 packages/subagent/subagent-fork/tsconfig.json create mode 100644 packages/subagent/subagent-spawn/README.md create mode 100644 packages/subagent/subagent-spawn/package.json create mode 100644 packages/subagent/subagent-spawn/src/in-process.ts create mode 100644 packages/subagent/subagent-spawn/src/index.ts create mode 100644 packages/subagent/subagent-spawn/tests/harness.ts create mode 100644 packages/subagent/subagent-spawn/tests/spawn.e2e.ts create mode 100644 packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts create mode 100644 packages/subagent/subagent-spawn/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index a68e13ccef..28d66fa686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise. ## Defensive patterns (hard-won) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 8bb2b6b398..36edfae505 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -332,7 +332,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:116`](../../packages/core/agent/src/index.ts) ### `ctx.bash` — `BashExecutor` (abstract seam) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 5ae83b40fa..ec58de91bf 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -86,3 +86,10 @@ interface SubagentProvider { ``` The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. + +## In-process backends: depth and seed + +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: + +- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`. +- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/module-graph.md b/docs/module-graph.md index b8d582838f..0724130ced 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -63,6 +63,10 @@ graph TD subagent-mock --> agent subagent-mock --> llm subagent-mock --> subagent + subagent-spawn --> agent + subagent-spawn --> llm + subagent-spawn --> session + subagent-spawn --> subagent tool-subagent --> agent tool-subagent --> llm tool-subagent --> subagent @@ -75,6 +79,10 @@ graph TD stdio-agent --> session stdio-agent --> session-persistence-jsonl stdio-agent --> ui-stdio + subagent-fork --> agent + subagent-fork --> session + subagent-fork --> subagent + subagent-fork --> subagent-spawn ``` | Package | Depends on | @@ -101,6 +109,8 @@ graph TD | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `subagent-mock` | `agent`, `llm`, `subagent` | +| `subagent-spawn` | `agent`, `llm`, `session`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | +| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-spawn` | diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index 553077d35d..02c4fa36b4 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -70,3 +70,4 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. +- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`, whose dispatch is a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and whose harness harvests a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needs per-session-keyed replay (or a call-ordered merge of both logs, sound because subagent execution is strictly nested/non-concurrent — the parent blocks on the child) plus harvest-all-logs and plural-session-id plumbing in the harness. This is self-contained infrastructure orthogonal to the backends, so it lands as a **dedicated stacked follow-up** rather than in the in-process-backends PR. Until it lands, in-process subagents are covered by real-loop unit tests (a parent driving a fork AND a spawn child) and a with-key e2e (a parent delegating to a child that writes a file), not by the snapshot transcript tier. Tracked by `TODO(subagent-snapshots)`. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 497aa8896a..c8371d3cba 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -48,12 +48,35 @@ systemPrompt: | You are coding-agent, a CLI coding assistant. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit - with sed or a rewrite. Each bash call runs in a fresh shell — pass - workdir instead of cd, and never rely on shell state between calls. + Your tools are bash (plus bash_output/bash_kill for background + tasks) and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd, and never rely on shell state + between calls. + + Use the subagent tool to delegate a focused, self-contained subtask + to a fresh child agent (it works in its own context and returns only + its final result) — give it a complete, standalone instruction. Check the [exit code: N] marker on every command; investigate failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. + +# The subagent seam + an in-process spawn backend + the model-facing `subagent` +# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The +# tool is bound to the `spawn` backend: a delegated task runs as a fresh child +# agent on this same process. (fork is available too — load dsh-subagent-fork +# and a second dsh-tool-subagent bound to it with a distinct toolName.) +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn diff --git a/knip.json b/knip.json index b3ce7c1d4b..aaf0e105d3 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,10 @@ "packages/ui/stdio-agent": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/subagent/subagent-spawn": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/packages/README.md b/packages/README.md index c2cba3461b..7cf32781bf 100644 --- a/packages/README.md +++ b/packages/README.md @@ -40,6 +40,8 @@ dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam) dsh-subagent-mock ← dsh-subagent (scripted provider for tests) +dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-process fresh child + shared run driver) +dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log) dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool) dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) @@ -74,6 +76,8 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | +| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent (+ the shared in-process run driver) | (registers on `ctx.subagents`) | +| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `brand/` | `util` | Type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 90d641eeeb..3179674366 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -140,16 +140,22 @@ export class AgentLoop extends Service implements AgentFactory { /** * Programmatic factory create ({@link AgentFactory}): an agent on a * caller-supplied `sessionId` (NOT `${id}-session`), with optional session - * metadata (validated `cwd`, lineage). The ACP bridge uses this so the - * client-generated session id becomes the live/persisted session id. Returns - * an {@link AgentHandle} the owner disposes to tear down exactly this agent. + * metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The + * ACP bridge uses this so the client-generated session id becomes the + * live/persisted session id; the in-process FORK subagent backend passes a + * `seed` (a balanced completed-turn prefix of the parent's log) so the child + * starts with the parent's context. Returns an {@link AgentHandle} the owner + * disposes to tear down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a // duplicate id only AFTER the session enters the store, leaving an orphaned // live session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) - const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} }) + const session = this.ctx.sessions.prepare(options.sessionId, { + ...options.seed !== undefined ? { seed: options.seed } : {}, + meta: options.meta ?? {}, + }) return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 158946178c..1f2984a148 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -6,7 +6,7 @@ */ import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentId, AgentOptions } from './types.ts' export * from './types.ts' @@ -37,6 +37,17 @@ export interface CreateAgentOptions { * excluded — a factory caller never sets it). */ meta?: { cwd?: string; parentSession?: SessionId } + /** + * Seed events to reconstruct the child session's log from (the fork lineage + * primitive). When present, the factory creates the session with this event + * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the + * in-process FORK subagent backend to seed a child with a balanced + * completed-turn prefix of the parent's log. The prefix MUST be contiguous + * from seq 0 and balanced (no open turn/step, no dangling tool-call), or the + * session constructor (and the dev-mode invariants replay) reject it. Absent + * for a fresh (spawn) child. + */ + seed?: SessionEvent[] /** Per-agent options (model, system prompt). */ agentOptions?: AgentOptions } diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 0fab2c610e..582172dfd1 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -5,8 +5,10 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) | +| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. Provider implementations live in their own packages — the in-process `dsh-subagent-spawn` / `dsh-subagent-fork` and the out-of-process `dsh-subagent-acp` — plus the test-only `dsh-subagent-mock` in [support](../support/README.md). All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md new file mode 100644 index 0000000000..c691d56355 --- /dev/null +++ b/packages/subagent/subagent-fork/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-subagent-fork + +The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. + +## The seed boundary (the crux) + +At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**. + +So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child. + +The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses. + +## Capabilities + +`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's). + +## Config + +| Key | Meaning | +|---|---| +| `providerName` | Registry name on `ctx.subagents` (default `fork`). | + +See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json new file mode 100644 index 0000000000..44027b4b0c --- /dev/null +++ b/packages/subagent/subagent-fork/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-subagent-fork", + "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-spawn": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts new file mode 100644 index 0000000000..6c730e225e --- /dev/null +++ b/packages/subagent/subagent-fork/src/index.ts @@ -0,0 +1,79 @@ +/** + * The in-process FORK subagent backend: registers a {@link SubagentProvider} on + * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a + * prefix of the parent's session log — so the child inherits the parent's + * conversation context instead of starting fresh. Shares the run driver with + * `@deepseek-ai/dsh-subagent-spawn`; the only difference is the seed. + * + * The seed boundary is the crux: at the moment a subagent tool's `execute` + * runs, the parent's CURRENT turn is open and unbalanced (it holds the + * `assistant/message` with this spawn's tool-call, plus the dangling `tool/call` + * with no `tool/result`). Seeding that raw prefix gives the child an open turn + * the session constructor and the dev-mode invariants replay REJECT. So the + * fork seeds only the **balanced completed-turn prefix**: the parent's log up + * to and including its last `turn/end`, excluding the in-flight turn entirely. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. + * + * @module @deepseek-ai/dsh-subagent-fork + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-spawn' + +export const name = 'subagent-fork' +export const inject = ['subagents', 'agents'] + +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `fork`). */ + providerName: string +} + +export const Config: z = z.object({ + providerName: z.string().default('fork'), +}) + +/** + * The balanced completed-turn prefix of `parent`'s log: every event up to and + * including the last `turn/end`. Empty if the parent has never completed a turn + * (the in-flight turn is excluded, so a parent on its very first turn forks an + * empty — i.e. fresh — child). The result is contiguous from seq 0 (the live + * log keeps `seq === index`), so it is a valid session seed; the in-flight, + * unbalanced turn is dropped so the invariants replay accepts it. + */ +export function completedTurnPrefix(parent: Agent): SessionEvent[] { + const events = parent.session.events + const lastEnd = events.findLast(e => e.type === 'turn/end') + if (lastEnd === undefined) return [] + // seq === array index (the append contract), so slice up to and including it. + return events.slice(0, lastEnd.seq + 1) +} + +/** + * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this + * cut (the service rejects a request needing either before `start` runs). + */ +class ForkProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context) {} + + start(request: SubagentStartRequest) { + const seed = completedTurnPrefix(request.parent) + return startInProcessRun(this.ctx, request, { + providerName: this.name, + // Only pass a seed when there's a completed turn to inherit; an empty seed + // is equivalent to a fresh child, so omit it to keep the session unseeded. + ...seed.length > 0 ? { seed } : {}, + }) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) +} diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts new file mode 100644 index 0000000000..1f932fbaf9 --- /dev/null +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as fork from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * The two in-process backends coexist on one context: the SAME parent agent + * delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log), + * and keeps working itself. This is the multi-provider coexistence the seam + * exists for — the named registry lets one runtime hold both transports. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + await ctx.plugin(fork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('multi-subagent coexistence (spawn + fork on one context)', () => { + it('both providers register and coexist', async () => { + const { ctx } = await setup([]) + expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn']) + }) + + it('the same parent drives a spawn child AND a fork child, then keeps working', async () => { + // Script order: parent turn 1, spawn child, fork child, parent turn 2. + const { ctx, parent } = await setup([ + textResponse('parent turn one'), + textResponse('spawn child reply'), + textResponse('fork child reply'), + textResponse('parent turn two'), + ]) + + // Parent does one real turn first, so the fork has a completed turn to seed. + parent.send([{ type: 'text', text: 'parent q1' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + // Delegate to a fresh spawn child. + const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent }) + const spawnResult = await spawnRun.result + expect(spawnResult.stopReason).toBe('completed') + expect(text(spawnResult.output)).toBe('spawn child reply') + + // Delegate to a fork child (seeded with the parent's turn-1 prefix). + const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent }) + const forkResult = await forkRun.result + expect(forkResult.stopReason).toBe('completed') + expect(text(forkResult.output)).toBe('fork child reply') + + // The two children are distinct sessions, both lineage-stamped to the parent. + const spawnChild = ctx.agents.get(spawnRun.id)! + const forkChild = ctx.agents.get(forkRun.id)! + expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id) + expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) + expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) + // The fork child inherited the parent's prefix; the spawn child did not. + expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) + + await spawnRun.dispose() + await forkRun.dispose() + + // The parent is unaffected and keeps working after both delegations. + parent.send([{ type: 'text', text: 'parent q2' }]) + await parent.whenIdle() + const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') + expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') + // The parent's OWN log never recorded the children's internal steps — its + // only subagent-related entries would be tool/call+tool/result IF it had + // used the tool, but here we called the service directly, so the parent log + // is purely its own two turns. + expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2) + }) +}) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts new file mode 100644 index 0000000000..d550101cb9 --- /dev/null +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as fork from '../src/index.ts' +import { completedTurnPrefix } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * Drives the REAL fork backend with a real loop + scripted mock MODEL + the + * real dsh-invariants plugin. The invariants plugin re-replays a seeded child + * log on `session/created` (its freeze-check), so a malformed (unbalanced) fork + * seed makes these tests THROW — that is the regression guard for the + * completed-turn-prefix boundary. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(fork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('completedTurnPrefix', () => { + it('returns an empty prefix for a parent that has never completed a turn', async () => { + const { parent } = await setup([]) + expect(completedTurnPrefix(parent)).toEqual([]) + }) + + it('returns the balanced prefix up to and including the last turn/end', async () => { + const { parent } = await setup([textResponse('first'), textResponse('second')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'q2' }]) + await parent.whenIdle() + + const prefix = completedTurnPrefix(parent) + // Ends exactly at the last turn/end; seq is contiguous from 0. + expect(prefix.at(-1)?.type).toBe('turn/end') + expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i)) + // Both completed turns are present. + expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2) + }) +}) + +describe('dsh-subagent-fork', () => { + it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => { + // The parent has never completed a turn → empty prefix → the provider omits + // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. + const { ctx, parent } = await setup([textResponse('fresh child')]) + expect(completedTurnPrefix(parent)).toEqual([]) + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('fresh child') + const child = ctx.agents.get(run.id)! + // Only the child's own turn — no seeded parent turns. + expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + await run.dispose() + }) + + it('seeds the child with the parent\'s completed-turn prefix (child inherits context)', async () => { + // Parent runs one turn, then we fork. The child's seeded log should contain + // the parent's first turn, and the child should run its own new turn on top. + const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child answer') + + const child = ctx.agents.get(run.id)! + // The child's log STARTS with the parent's prefix (seeded), then its own turn. + expect(child.session.events.length).toBeGreaterThan(parentPrefixLen) + // The seeded prefix carried the parent's user message. + const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message') + expect(seededUser).toBeDefined() + // Lineage stamped. + expect(child.session.header.parentSession).toBe(parent.session.header.id) + await run.dispose() + }) + + it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => { + // Drive the parent so it has ONE completed turn, then start a SECOND turn + // that is still open (a hanging model call), and fork while it's in flight. + // The fork must seed only the completed first turn — an unbalanced seed + // would make the invariants replay throw inside ctx.subagents.start. + const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + // Start a second turn that hangs (open turn/start + open step, never ends). + parent.send([{ type: 'text', text: 'q2' }]) + await new Promise(r => setTimeout(r, 20)) // let the hanging turn open + + // Forking now must NOT throw (the open second turn is excluded from the seed). + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child') + + const child = ctx.agents.get(run.id)! + // The child's seed has exactly the ONE completed parent turn (the open one excluded). + const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end') + // 1 from the seeded parent turn + 1 from the child's own completed turn. + expect(seedTurnEnds.length).toBe(2) + + parent.cancel() + await run.dispose() + }) + + it('advertises depthLimit but not outputSchema/toolFilter', async () => { + const { ctx } = await setup([]) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(fork, { providerName: 'fork' }) + expect(ctx.subagents.list()).toEqual(['fork']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in fork).toBe(false) + expect(fork.name).toBe('subagent-fork') + expect(fork.inject).toEqual(['subagents', 'agents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(fork) as Record + expect(unwrapped).toBe(fork) + expect(unwrapped.name).toBe('subagent-fork') + expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json new file mode 100644 index 0000000000..d05e0f6081 --- /dev/null +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../subagent-spawn" + } + ] +} diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md new file mode 100644 index 0000000000..4e6e22f68d --- /dev/null +++ b/packages/subagent/subagent-spawn/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subagent-spawn + +The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown. + +It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed. + +## What it does + +`start(request)` → +1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); +2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited); +3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); +4. reads the result: the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. + +`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. + +## Capabilities + +`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs). + +## Config + +| Key | Meaning | +|---|---| +| `providerName` | Registry name on `ctx.subagents` (default `spawn`). | + +## Depth tracking + +Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. Read it with the exported `depthOf(agent)`. diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json new file mode 100644 index 0000000000..184296f01a --- /dev/null +++ b/packages/subagent/subagent-spawn/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-subagent-spawn", + "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts new file mode 100644 index 0000000000..4b40bb667d --- /dev/null +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -0,0 +1,164 @@ +/** + * The shared in-process subagent run driver. A subagent backend that runs the + * child as a child {@link Agent} on the SAME cordis context (`ctx.agents`) — + * the cheapest transport, reusing the agent factory's quiescent + * {@link AgentHandle} teardown. Both in-process backends use this: + * `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and + * `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the + * parent's log) differ ONLY in the `seed` they pass — everything downstream + * (drive the child, read its final output, map the stop reason, dispose) is + * identical and lives here. + * + * @module @deepseek-ai/dsh-subagent-spawn/in-process + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** + * The agent's delegation depth in the subagent tree — 0 for a top-level + * (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the + * in-process backends on every child they create so a nested spawn reads its + * parent's depth from `parent.options.subagentDepth` and the `depthLimit` + * capability can cap the tree. Merge-extensible field (the seam owns it; the + * loop neither sets nor reads it). + */ + subagentDepth?: number + } +} + +/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */ +export function depthOf(agent: Agent): number { + return agent.options.subagentDepth ?? 0 +} + +/** Thrown when a spawn would exceed the request's `maxDepth` cap. */ +export class SubagentDepthError extends Error { + constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { + super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) + this.name = 'SubagentDepthError' + } +} + +/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */ +function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { + switch (reason?.kind) { + case 'completed': + return 'completed' + case 'max-tokens': + return 'max-tokens' + case 'aborted': + return 'aborted' + // `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean + // the turn did not finish cleanly; surface them as a generic failure rather + // than a clean completion. A missing reason (no turn ran) is also an error. + case 'error': + case 'disposed': + case 'interrupted': + default: + return 'error' + } +} + +/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */ +export interface InProcessRunOptions { + /** The provider name (`spawn`/`fork`), for error context only. */ + readonly providerName: string + /** + * The child session's seed: a balanced, contiguous-from-0 prefix of the + * parent's log (FORK), or `undefined` for a fresh child (SPAWN). + */ + readonly seed?: SessionEvent[] +} + +/** + * Start an in-process child agent for `request` and return a {@link SubagentRun}. + * + * Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering + * matters — `send` enqueues synchronously, so `whenIdle` observes the queued + * work and resolves only on the child's `running → idle` transition, never + * before the turn starts). The final `assistant/message` is the result output, + * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the + * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove + * session); `cancel()` cancels the child's in-flight turn. + */ +export function startInProcessRun( + ctx: Context, + request: SubagentStartRequest, + options: InProcessRunOptions, +): SubagentRun { + const childDepth = depthOf(request.parent) + 1 + if (request.maxDepth !== undefined && childDepth > request.maxDepth) { + throw new SubagentDepthError(childDepth, request.maxDepth) + } + + const childId = AgentId(randomUUID()) + const parentHeader = request.parent.session.header + // Inherit the parent's model by default (a child with no model cannot run); + // an explicit `request.agentOptions.model` overrides it. The parent's + // systemPrompt is NOT inherited — a fresh child is a clean specialist unless + // the caller supplies one. + const agentOptions: AgentOptions = { + ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, + ...request.agentOptions, + subagentDepth: childDepth, + } + + const handle: AgentHandle = ctx.agents.create({ + agentId: childId, + sessionId: SessionId(randomUUID()), + meta: { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + }, + ...options.seed !== undefined ? { seed: options.seed } : {}, + agentOptions, + }) + const child = handle.agent + + // Bridge the request's abort signal to the child (the consumer also bridges + // its own exec.signal, but a backend-level bridge keeps the contract local). + const onAbort = (): void => { child.cancel('subagent cancelled') } + request.signal?.addEventListener('abort', onAbort, { once: true }) + + const result: Promise = (async () => { + try { + child.send(request.prompt) + await child.whenIdle() + return readResult(child) + } finally { + request.signal?.removeEventListener('abort', onAbort) + } + })() + + return { + id: childId, + result, + cancel(reason?: string): void { + child.cancel(reason ?? 'subagent cancelled') + }, + async dispose(): Promise { + request.signal?.removeEventListener('abort', onAbort) + await handle.dispose() + }, + } +} + +/** + * Read a settled child's terminal result from its session log: the last + * `assistant/message` content (deep-cloned — the log is frozen) and the last + * `turn/end` reason mapped to a {@link SubagentStopReason}. + */ +function readResult(child: Agent): SubagentResult { + const events = child.session.events + const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') + const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') + const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] + return { output, stopReason: toStopReason(lastEnd?.data.reason) } +} diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts new file mode 100644 index 0000000000..bbfcc03719 --- /dev/null +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -0,0 +1,57 @@ +/** + * The in-process SPAWN subagent backend: registers a {@link SubagentProvider} + * on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the + * same cordis context (its own session, own system prompt, zero parent + * context). The cheapest transport, reusing the agent factory's quiescent + * teardown. + * + * The fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run + * driver ({@link startInProcessRun}) and differs ONLY in seeding the child with + * a prefix of the parent's log. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. + * + * @module @deepseek-ai/dsh-subagent-spawn + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { startInProcessRun } from './in-process.ts' + +export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts' +export type { InProcessRunOptions } from './in-process.ts' + +export const name = 'subagent-spawn' +export const inject = ['subagents', 'agents'] + +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `spawn`). */ + providerName: string +} + +export const Config: z = z.object({ + providerName: z.string().default('spawn'), +}) + +/** + * The spawn provider. Supports `depthLimit` (it constructs the child, so it can + * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut — + * a request that needs either is rejected by the service before `start` runs. + */ +class SpawnProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context) {} + + start(request: SubagentStartRequest) { + // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ + // depth, drives the one-shot, and maps the result. + return startInProcessRun(this.ctx, request, { providerName: this.name }) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) +} diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts new file mode 100644 index 0000000000..ff551cfc3f --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -0,0 +1,49 @@ +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as Spawn from '../src/index.ts' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' + +/** + * Shared harness for the spawn-backend e2e: the full real stack (DeepSeek + * adapter + real bash tool + the subagent tool bound to the spawn backend), so + * a real parent agent can delegate to a real in-process child that does real + * work (writes a file). Lives outside the *.e2e.ts pattern so importing it never + * re-registers another file's tests. + */ +export async function spawnHarness(workdir: string): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) + await ctx.plugin(ToolBash) + await ctx.plugin(SubagentService) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + // The model-facing subagent tool, bound to the spawn backend. + await ctx.plugin(ToolSubagent, { provider: 'spawn' }) + return ctx +} + +export function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts new file mode 100644 index 0000000000..8179027976 --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -0,0 +1,54 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { spawnHarness, waitForIdle } from './harness.ts' + +/** + * With-key smoke for the in-process spawn backend: a REAL parent agent delegates + * to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL + * bash tool to write a file, and we verify the WORLD (the file on disk) — not + * the agent's self-report. This is the "green units, broken product" guard: + * mocks prove the plumbing, only a real model proves a parent can actually drive + * a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY). + */ + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', () => { + it('a parent delegates to a child that writes a file on disk', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) + ctx = await spawnHarness(workdir) + const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { + model: 'deepseek-v4-flash', + systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — ' + + 'give it a complete, standalone instruction. Report only when done.', + }) + + parent.send([{ type: 'text', text: + 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' + + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + + 'After the subagent finishes, tell me it is done.' }]) + await waitForIdle(ctx, parent) + + // Verify the WORLD: the child actually wrote the file. + const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') + expect(proof).toContain('SUBAGENT_WAS_HERE') + + // The parent's log records the subagent tool/call + its result (not the + // child's internal steps). + const events = [...parent.session.events] + const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') + expect(subagentCalls.length).toBeGreaterThan(0) + }, 180_000) +}) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts new file mode 100644 index 0000000000..830aa81ec8 --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as spawn from '../src/index.ts' +import { depthOf, SubagentDepthError } from '../src/in-process.ts' + +type Script = ConstructorParameters[0] + +/** + * Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock + * MODEL (the only mocked boundary) + the real SubagentService + the real + * dsh-invariants plugin (so a malformed child session log would fail the test). + * The parent is a real config agent; the spawn provider creates a real child + * agent on the same context and we assert its output. + */ +async function setup(script: Script) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent, adapter } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('dsh-subagent-spawn', () => { + it('runs a fresh child to completion and returns its final assistant output', async () => { + // One model call for the child: a plain text answer. + const { ctx, parent } = await setup([textResponse('child answer')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child answer') + await run.dispose() + }) + + it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => { + const { ctx, parent } = await setup([textResponse('hi')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.id).not.toBe(parent.session.header.id) + expect(child.session.header.parentSession).toBe(parent.session.header.id) + await run.dispose() + }) + + it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { + // Drive the parent through one real turn so it has history, THEN spawn. + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) + parent.send([{ type: 'text', text: 'parent prompt' }]) + await parent.whenIdle() + const parentEventCount = parent.session.events.length + expect(parentEventCount).toBeGreaterThan(0) + + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + // The child's first user/message is its OWN prompt, not the parent's history. + const firstUser = child.session.events.find(e => e.type === 'user/message') + expect(firstUser).toBeDefined() + await run.dispose() + }) + + it('disposes the child to quiescence (agent removed from the registry)', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + expect(ctx.agents.get(run.id)).toBeDefined() + await run.dispose() + // After dispose, the child is unregistered (the AgentHandle teardown ran). + expect(ctx.agents.get(run.id)).toBeUndefined() + }) + + it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + expect(depthOf(parent)).toBe(0) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(depthOf(child)).toBe(1) + await run.dispose() + }) + + it('refuses to spawn past maxDepth (depthLimit capability)', async () => { + const { ctx, parent } = await setup([]) + // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. + expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) + .toThrow(SubagentDepthError) + }) + + it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { + const { ctx, parent } = await setup([maxTokensResponse('cut off')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + await run.dispose() + }) + + it('maps a child whose turn errored (script exhausted) to stopReason "error" with empty output', async () => { + // Empty script: the child's first model call throws "script exhausted", the + // turn ends `error`, and there is no assistant/message → empty output. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { + // 'hang' makes the child's model stream one chunk then wait until aborted. + const controller = new AbortController() + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + // Let the child's turn start, then abort via the request signal (the + // backend bridges it to child.cancel()). + await new Promise(r => setTimeout(r, 30)) + controller.abort() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('run.cancel() also cancels the child directly', async () => { + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await new Promise(r => setTimeout(r, 30)) + run.cancel('test cancel') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('run.cancel() with no reason uses the default cancel reason', async () => { + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await new Promise(r => setTimeout(r, 30)) + run.cancel() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + expect('sendMessage' in run).toBe(false) + expect('resume' in run).toBe(false) + await run.result + await run.dispose() + }) + + it('inherits the parent cwd into the child session', async () => { + const { ctx } = await setup([textResponse('x')]) + // A parent WITH a cwd (config agents have none, so create one explicitly). + const parentHandle = ctx.agents.create({ + agentId: AgentId('cwd-parent'), + sessionId: SessionId('cwd-parent-session'), + meta: { cwd: '/tmp/parent-workspace' }, + agentOptions: { model: 'mock' }, + }) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.cwd).toBe('/tmp/parent-workspace') + await run.dispose() + await parentHandle.dispose() + }) + + it('uses request.agentOptions.model when the parent has no model of its own', async () => { + const { ctx } = await setup([textResponse('explicit model child')]) + // A parent with NO model (its own turns would need one supplied per-request). + const parentHandle = ctx.agents.create({ + agentId: AgentId('modelless-parent'), + sessionId: SessionId('modelless-parent-session'), + agentOptions: {}, + }) + // The request supplies the child's model explicitly. + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'p' }], + parent: parentHandle.agent, + agentOptions: { model: 'mock' }, + }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('explicit model child') + await run.dispose() + await parentHandle.dispose() + }) + + it('advertises depthLimit but not outputSchema/toolFilter', async () => { + const { ctx } = await setup([]) + const provider = ctx.subagents.getProvider('spawn')! + expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + expect(ctx.subagents.list()).toEqual(['spawn']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in spawn).toBe(false) + expect(spawn.name).toBe('subagent-spawn') + expect(spawn.inject).toEqual(['subagents', 'agents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(spawn) as Record + expect(unwrapped).toBe(spawn) + expect(unwrapped.name).toBe('subagent-spawn') + expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json new file mode 100644 index 0000000000..5e6c9f9100 --- /dev/null +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 67fcf09697..fe24213768 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -211,6 +211,16 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) * the snapshot harness runs one ACP session per scenario to guarantee that. The * cursor is advanced synchronously at listener-invocation time (not lazily * inside the generator) so call ORDER, not iteration order, fixes the mapping. + * + * TODO(subagent-snapshots): this single global cursor cannot route calls to the + * right agent when a parent and an in-process subagent both stream on one ctx. + * Snapshot coverage of nested agents needs either per-session-keyed replay (a + * `Map` fed by the calling agent on the `agent/request` + * waterfall, which carries the agent) or a call-ordered merge of the parent and + * child session logs (sound because subagent execution is strictly nested — + * the parent blocks on the child). Tracked as a stacked follow-up to the + * in-process subagent backends; see the subagent RFC's "Snapshot coverage of + * nested agents" deferral. */ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void { const entries = loadReplayScript(config) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d56ed4e19..b262257b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,95 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-fork: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/subagent-spawn: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../tool-subagent + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/tool-subagent: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index 44f984a784..4f0528961d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,6 +34,8 @@ { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, - { "path": "./packages/subagent/tool-subagent" } + { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-spawn" }, + { "path": "./packages/subagent/subagent-fork" } ] } From 07f4047ff0d0d5e8f4eb24b0747e1a92b578b424 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 06:11:00 +0800 Subject: [PATCH 21/40] Use explicit ts specifiers for declarations Restore explicit .ts relative specifiers in source and enable rewriteRelativeImportExtensions so emitted JS uses .js while declarations keep explicit .ts specifiers. Add a NodeNext declaration-consumer gate to prevent extensionless declaration regressions. --- .github/workflows/ci.yml | 7 +- AGENTS.md | 6 +- docs/cookbook/adding-a-package.md | 2 + docs/cookbook/adding-a-vendored-package.md | 2 + docs/development.md | 5 +- .../process/2026-06-11-quality-gates.md | 2 +- .../process/2026-06-17-ts-build-config.md | 9 +- package.json | 3 +- packages/README.md | 2 +- packages/bash/bash-local/src/index.ts | 8 +- packages/bash/bash/src/index.ts | 6 +- packages/core/agent-loop/src/agent.ts | 4 +- packages/core/agent-loop/src/index.ts | 8 +- packages/core/agent-loop/src/loop.ts | 2 +- packages/core/agent/src/index.ts | 4 +- packages/core/session/src/index.ts | 12 +- packages/core/session/src/repair.ts | 2 +- packages/core/tools/src/index.ts | 2 +- packages/core/tools/src/schema.ts | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 10 +- packages/llm/llm-deepseek/src/index.ts | 16 +- packages/llm/llm-deepseek/src/serialize.ts | 2 +- packages/llm/llm-deepseek/src/translate.ts | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/index.ts | 10 +- packages/llm/llm/src/assembler.ts | 6 +- packages/llm/llm/src/index.ts | 14 +- packages/llm/llm/src/types.ts | 2 +- .../session-persistence-jsonl/src/index.ts | 2 +- .../session-persistence-sqlite/src/index.ts | 4 +- .../session-persistence/src/coordinator.ts | 2 +- .../session-persistence/src/index.ts | 4 +- packages/ui/acp/src/index.ts | 2 +- scripts/verify-node-next-types.ts | 160 ++++++++++++++++++ tsconfig.base.json | 2 + vendor/README.md | 2 +- vendor/cordis/src/context.ts | 12 +- vendor/cordis/src/events.ts | 8 +- vendor/cordis/src/fiber.ts | 10 +- vendor/cordis/src/index.ts | 14 +- vendor/cordis/src/logger.ts | 8 +- vendor/cordis/src/reflect.ts | 8 +- vendor/cordis/src/registry.ts | 8 +- vendor/cordis/src/service.ts | 4 +- vendor/cordis/src/utils.ts | 2 +- vendor/cosmokit/src/array.ts | 2 +- vendor/cosmokit/src/index.ts | 10 +- vendor/cosmokit/src/types.ts | 2 +- vendor/hmr/src/index.ts | 2 +- vendor/loader/src/config/entry.ts | 8 +- vendor/loader/src/config/group.ts | 4 +- vendor/loader/src/config/isolate.ts | 4 +- vendor/loader/src/config/tree.ts | 4 +- vendor/loader/src/index.ts | 20 +-- vendor/logger-console/src/browser.ts | 4 +- vendor/logger-console/src/index.ts | 4 +- 56 files changed, 323 insertions(+), 147 deletions(-) create mode 100644 scripts/verify-node-next-types.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ca887219..f164e3c4c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,12 +69,13 @@ jobs: run: pnpm run test:snapshot # Before hygiene: publint validates the packed artifacts (lib/index.js), - # which only the tsdown bundling step emits. + # which only the tsdown bundling step emits, and verify-node-next-types + # validates the built declarations. - name: Build (tsc -b + tsdown bundles) run: pnpm run build - - name: Hygiene (knip + publint) - run: pnpm run knip && pnpm run publint + - name: Hygiene (knip + publint + constraints + NodeNext types) + run: pnpm run hygiene - name: Demo smoke test run: | diff --git a/AGENTS.md b/AGENTS.md index 2b38f32360..d7340c631d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,7 +145,7 @@ pnpm run lint:fix # eslint . --fix pnpm run build # tsc emits lib/types, then tsdown bundles runtime lib/index.* pnpm run knip # dead-code / unused-dependency check pnpm run publint # package.json publish-correctness check (every packages/*/* package) -pnpm run hygiene # knip + publint + workspace constraints +pnpm run hygiene # knip + publint + workspace constraints + NodeNext type-consumer check pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, # packages/*/*.md + packages/*/*/*.md (doc/code drift gate) pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md @@ -160,6 +160,8 @@ pnpm run verify-package-paths # assert every packages/ cited in Markdown pnpm run verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) +pnpm run verify-node-next-types # assert built declarations typecheck for a + # standard external NodeNext ESM TypeScript consumer pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton @@ -188,7 +190,7 @@ Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsco ## Conventions - **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-` (vendored packages keep their upstream names and are `private: true`). -- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports are extensionless so generated `.d.ts` files stay extensionless; `lib/types/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. +- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports use explicit `.ts` extensions; `rewriteRelativeImportExtensions` turns those into `.js` in emitted JS, while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve to sibling `.d.ts` files. `lib/types/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. - **`cordis` is a peerDependency** (+ devDependency) of every harness package, mirroring upstream convention. - **Registrations are effects**: anything a plugin contributes (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so disposal and HMR work. If you write a registry, `register()` must return the disposer. - **Typed events via declaration merging**: services declare their events in `declare module 'cordis' { interface Events { … } }`, and their ctx key in `interface Context`. Extensible unions use the merge-extensible-map pattern (see `ContentBlockMap`, `MessageSourceMap`). diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 991db3be60..593a0a93ba 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -17,6 +17,8 @@ packages// package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. + ## 2. Register it in the root configs | File | Change | diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 4f411c6f04..59df2f617b 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -29,6 +29,8 @@ vendor// `package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build-shape divergence from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. + ## 2. Register it in the root configs | File | Change | diff --git a/docs/development.md b/docs/development.md index 5e4bf5d1e9..f2206d30c0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,7 +39,7 @@ If you are preparing to push from a fresh clone or worktree, also build once: pnpm run build ``` -`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files. A fresh worktree has no bundled JS until `pnpm run build` runs. +`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs. ## Environment variables @@ -101,7 +101,8 @@ pnpm run doc-sync # doc-typecheck, cordis-catalog freshness, markdown wrap pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files -pnpm run hygiene # knip, publint, and workspace constraints +pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable +pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check ``` When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, cordis events/services catalog drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 3b41613b1a..277a56fa2b 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.md @@ -15,7 +15,7 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks - Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary. - ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded. - Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. -- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM). +- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. - lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a demo smoke test driving the echo-agent end to end. ## Consequences diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md index f0a52d8d6a..fdcc85aa38 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -17,7 +17,7 @@ Validation found several concrete technical issues and possible routes: - `tsdown` uses `oxc` to transform TypeScript, which is not the same behavior as `tsc`. - Bundled `.d.ts` emitted by `tsdown` conflicts with Cordis' internal relative module augmentation shape. - - The tsc output is affected by `allowImportingTsExtensions`, so we need to ensure that generated `.js` files do not import `.ts` files and generated `.d.ts` files do not import `.js` files. Therefore, we need to adjust the import specifiers to extensionless in the TypeScript source. + - The tsc output is affected by `allowImportingTsExtensions`, so we need to ensure that generated `.js` files do not import `.ts` files and generated `.d.ts` files do not contain extensionless relative imports. Therefore, in-package relative imports use explicit `.ts` specifiers in TypeScript source and `rewriteRelativeImportExtensions` rewrites those specifiers to `.js` in emitted JS. - Bundled `.js` emitted by `tsdown` is not the same behavior as per-file `.js` emitted by `tsc -b`, such as decorator transform behavior. - `vendor/*/src`, examples, tests, and scripts cannot all be plain-included in one root strict program. - Directly typechecking `vendor/*/src` under the root strict config triggers many type errors outside this project's ownership. @@ -26,7 +26,7 @@ Validation found several concrete technical issues and possible routes: ## Decision -In-package relative imports are extensionless. +In-package relative imports use explicit `.ts` specifiers. `pnpm run build` is a two-stage build: @@ -47,6 +47,9 @@ pnpm run build: tsc -b tsconfig.build.json tsdown +pnpm run verify-node-next-types: +tsx scripts/verify-node-next-types.ts + pnpm run typecheck: tsc -b tsconfig.json ``` @@ -60,8 +63,10 @@ Build responsibilities are clearer: - Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. - The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. + - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. +- `pnpm run verify-node-next-types` scans built declarations for extensionless relative specifiers, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. diff --git a/package.json b/package.json index 1e6155ff94..2e83cdca61 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,14 @@ "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", diff --git a/packages/README.md b/packages/README.md index 976c9f7ac7..1fcc0c44d0 100644 --- a/packages/README.md +++ b/packages/README.md @@ -79,5 +79,5 @@ Each package has its own `README.md` with purpose, service API, events, extensio - **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism). - **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging. -- **ESM everywhere**; imports use package names across package boundaries and extensionless relative specifiers within a package. +- **ESM everywhere**; imports use package names across package boundaries and explicit `.ts` relative specifiers within a package. - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index b2d117324b..05f1ed75dd 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -17,11 +17,11 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' -import { runBash } from './run' -import type { RunInternals, RunningBash } from './run' +import { runBash } from './run.ts' +import type { RunInternals, RunningBash } from './run.ts' -export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run' -export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run' +export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' +export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts' /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index b8d7c619e1..01c5c081c3 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -15,9 +15,9 @@ */ import { Context, Service } from 'cordis' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts' -export { BashTaskId, OwnerToken } from './types' +export { BashTaskId, OwnerToken } from './types.ts' export type { BashExecRequest, BashExecSpec, @@ -27,7 +27,7 @@ export type { BashTaskRead, BashTaskStatus, CollectedOutput, -} from './types' +} from './types.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c6188c9a7e..402a9a8416 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -11,8 +11,8 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' -import { Inbox } from './inbox' -import { isTurnOpen, lastTurnNumber, runLoop } from './loop' +import { Inbox } from './inbox.ts' +import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** * The concrete {@link Agent} implementation owned by the agent-loop plugin. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 86fe091b85..90d641eeeb 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -17,11 +17,11 @@ import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { ReactLoopAgent } from './agent' +import { ReactLoopAgent } from './agent.ts' -export { ReactLoopAgent } from './agent' -export { Inbox, type InboxMessage } from './inbox' -export { runLoop } from './loop' +export { ReactLoopAgent } from './agent.ts' +export { Inbox, type InboxMessage } from './inbox.ts' +export { runLoop } from './loop.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 443f98d13a..8d19c464fd 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -13,7 +13,7 @@ import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { ReactLoopAgent } from './agent' +import type { ReactLoopAgent } from './agent.ts' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ type CodedError = Error & { code?: string } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b39d8343d7..158946178c 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -7,9 +7,9 @@ import { Context, Service } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentId, AgentOptions } from './types' +import type { Agent, AgentId, AgentOptions } from './types.ts' -export * from './types' +export * from './types.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 56d8352c36..cef91c110c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -9,13 +9,13 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, SessionId } from './types' -import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types' -import { isJsonValue } from './json' +import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' +import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts' +import { isJsonValue } from './json.ts' -export * from './types' -export { isJsonValue } from './json' -export { interruptedTurnClosers } from './repair' +export * from './types.ts' +export { isJsonValue } from './json.ts' +export { interruptedTurnClosers } from './repair.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index 6215ebc2a8..5cc62b37c7 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -36,7 +36,7 @@ */ import type { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from './types' +import type { SessionEvent } from './types.ts' /** * Scan `events` for an open turn/step at the tail and return the synthetic diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 6a70cfdd01..5a17aa2b0c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -24,7 +24,7 @@ export { type InferArgs, type DefineToolOptions, type JsonSchemaObject, -} from './schema' +} from './schema.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 16b46cd5d7..b717eabf9a 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -21,7 +21,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index' +import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts' // --------------------------------------------------------------------------- // SchemaSpec — the author-facing per-property type diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index f9250987f3..fda527359a 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -7,11 +7,11 @@ import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { serializeRequest } from './serialize' -import type { RequestDefaults } from './serialize' -import { parseSse } from './sse' -import { translate } from './translate' -import type { WireError } from './types' +import { serializeRequest } from './serialize.ts' +import type { RequestDefaults } from './serialize.ts' +import { parseSse } from './sse.ts' +import { translate } from './translate.ts' +import type { WireError } from './types.ts' export interface DeepSeekAdapterOptions { apiKey: string diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f4f7e43635..79313f910f 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -21,15 +21,15 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' -import { DeepSeekAdapter } from './adapter' +import { DeepSeekAdapter } from './adapter.ts' -export { DeepSeekAdapter, httpErrorCode } from './adapter' -export type { DeepSeekAdapterOptions } from './adapter' -export { serializeMessages, serializeRequest } from './serialize' -export type { RequestDefaults } from './serialize' -export { DONE, parseSse } from './sse' -export { mapFinishReason, mapUsage, translate } from './translate' -export type * from './types' +export { DeepSeekAdapter, httpErrorCode } from './adapter.ts' +export type { DeepSeekAdapterOptions } from './adapter.ts' +export { serializeMessages, serializeRequest } from './serialize.ts' +export type { RequestDefaults } from './serialize.ts' +export { DONE, parseSse } from './sse.ts' +export { mapFinishReason, mapUsage, translate } from './translate.ts' +export type * from './types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 11b9028af0..4e967d6667 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -18,7 +18,7 @@ import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { WireMessage, WireRequest, WireTool } from './types' +import type { WireMessage, WireRequest, WireTool } from './types.ts' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index ea5e50d7c1..08cc019b61 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -16,8 +16,8 @@ import { CallId, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' -import { DONE } from './sse' -import type { WireChunk, WireUsage } from './types' +import { DONE } from './sse.ts' +import type { WireChunk, WireUsage } from './types.ts' /** One open block under assembly. */ interface OpenBlock { diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 0f92da4bed..e15cce8252 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -16,7 +16,7 @@ import type { Model } from '@earendil-works/pi-ai' import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' -import { toPiContext, toStreamChunks } from './convert' +import { toPiContext, toStreamChunks } from './convert.ts' /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */ export type PiAiReasoning = 'off' | 'high' | 'xhigh' diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index d146df5824..bef0d4b3f5 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -19,12 +19,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' -import { PiAiAdapter } from './adapter' -import type { PiAiReasoning } from './adapter' +import { PiAiAdapter } from './adapter.ts' +import type { PiAiReasoning } from './adapter.ts' -export { buildModel, PiAiAdapter } from './adapter' -export type { PiAiAdapterOptions, PiAiReasoning } from './adapter' -export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert' +export { buildModel, PiAiAdapter } from './adapter.ts' +export type { PiAiAdapterOptions, PiAiReasoning } from './adapter.ts' +export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index fd13c34ad1..328ef01c54 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -6,9 +6,9 @@ * @module @deepseek-ai/dsh-llm/assembler */ -import { CallId } from './brand' -import { assertNever } from './never' -import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types' +import { CallId } from './brand.ts' +import { assertNever } from './never.ts' +import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types.ts' interface PartialBlock { blockType: string diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 2f9e51e215..320838a8a6 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,14 +7,14 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, StreamChunk } from './types' -import { HarnessError } from './error' +import type { GenerateOptions, StreamChunk } from './types.ts' +import { HarnessError } from './error.ts' -export * from './brand' -export * from './never' -export * from './error' -export * from './types' -export { BlockAssembler } from './assembler' +export * from './brand.ts' +export * from './never.ts' +export * from './error.ts' +export * from './types.ts' +export { BlockAssembler } from './assembler.ts' declare module 'cordis' { interface Context { diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 158492d99c..63fc0f5b0c 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -19,7 +19,7 @@ * ``` */ -import type { CallId } from './brand' +import type { CallId } from './brand.ts' /** Cache hint attached to a content block (provider-interpreted). */ export type CacheHint = 'ephemeral' diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 6992b4fa53..6de7eafeb3 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -29,7 +29,7 @@ import { import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, -} from './format' +} from './format.ts' export interface Config { /** diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 701ab6e0bc..cef61cb071 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -29,9 +29,9 @@ import { import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, -} from './schema' +} from './schema.ts' -export { SCHEMA_VERSION } from './schema' +export { SCHEMA_VERSION } from './schema.ts' /** Plugin configuration. */ export interface Config { diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index ace8125ce2..7c7b044ee1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -27,7 +27,7 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix } from './index' +import { assertSerializable, seedCoversPrefix } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 239feb2825..a9ffd11792 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -29,8 +29,8 @@ import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-se export type { SessionHeader } from '@deepseek-ai/dsh-session' // The backend-agnostic write-path orchestration first-party backends compose. -export { PersistenceCoordinator } from './coordinator' -export type { PersistenceBackend, StoredPrefix } from './coordinator' +export { PersistenceCoordinator } from './coordinator.ts' +export type { PersistenceBackend, StoredPrefix } from './coordinator.ts' declare module 'cordis' { interface Context { diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 159fcb8044..ec79e97443 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -74,7 +74,7 @@ import { harnessBlockToAcpContent, promptHasUnsupportedContent, turnEndToStopReason, -} from './codec' +} from './codec.ts' export const name = 'acp' // The bridge programs against the interface packages only (architecture rule: diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts new file mode 100644 index 0000000000..0f2e392666 --- /dev/null +++ b/scripts/verify-node-next-types.ts @@ -0,0 +1,160 @@ +/** + * Verify that built package declarations are consumable by a standard external + * TypeScript ESM project using NodeNext resolution. + * + * Run after `pnpm run build` has emitted declaration files under package + * `lib/types` directories. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') + +interface ExportTarget { + types?: string +} + +interface PackageManifest { + name?: string + types?: string + exports?: Record +} + +interface WorkspacePackage { + dir: string + name: string + manifest: PackageManifest +} + +function readPackage(path: string): WorkspacePackage | null { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest + if (!manifest.name) return null + return { dir: dirname(path), name: manifest.name, manifest } +} + +function workspacePackages(): WorkspacePackage[] { + return [ + ...globSync('vendor/*/package.json', { cwd: root }), + ...globSync('packages/*/*/package.json', { cwd: root }), + ] + .map(path => readPackage(resolve(root, path))) + .filter(pkg => pkg !== null) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g +const hasExtension = /\.[^/.]+$/ + +function extensionlessRelativeSpecifiers(): string[] { + const errors: string[] = [] + const files = [ + ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }), + ...globSync('packages/*/*/lib/types/**/*.d.ts', { cwd: root }), + ].sort() + + for (const file of files) { + const text = readFileSync(resolve(root, file), 'utf8') + for (const match of text.matchAll(declarationSpecifierPattern)) { + const specifier = match[1] + if (!specifier) continue + const isRelative = specifier === '.' || specifier.startsWith('./') || specifier.startsWith('../') + if (isRelative && !hasExtension.test(specifier)) errors.push(`${file}: ${specifier}`) + } + } + + return errors +} + +function publicSpecifiers(pkg: WorkspacePackage): string[] { + const specifiers = new Set() + if (pkg.manifest.types) specifiers.add(pkg.name) + + for (const [key, target] of Object.entries(pkg.manifest.exports ?? {})) { + if (key.includes('*') || key === './package.json') continue + if (typeof target !== 'object' || target === null || !target.types) continue + specifiers.add(key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`) + } + + return [...specifiers].sort() +} + +function linkPackage(pkg: WorkspacePackage, nodeModules: string): void { + const parts = pkg.name.split('/') + const link = resolve(nodeModules, ...parts) + mkdirSync(dirname(link), { recursive: true }) + symlinkSync(pkg.dir, link, 'dir') +} + +const packages = workspacePackages() +const badSpecifiers = extensionlessRelativeSpecifiers() +if (badSpecifiers.length > 0) { + console.error('verify-node-next-types: declaration files still contain extensionless relative specifiers.') + console.error(badSpecifiers.join('\n')) + process.exit(1) +} + +const missingOutputs = packages + .filter(pkg => pkg.manifest.types && !existsSync(resolve(pkg.dir, pkg.manifest.types))) + .map(pkg => `${pkg.name}: missing ${pkg.manifest.types}`) + +if (missingOutputs.length > 0) { + console.error('verify-node-next-types: build outputs are missing; run `pnpm run build` first.') + console.error(missingOutputs.join('\n')) + process.exit(1) +} + +const tmp = mkdtempSync(resolve(root, '.node-next-types-')) +let failed = false + +try { + const nodeModules = resolve(tmp, 'node_modules') + mkdirSync(nodeModules, { recursive: true }) + for (const pkg of packages) linkPackage(pkg, nodeModules) + + const rootTypes = resolve(root, 'node_modules/@types/node') + if (existsSync(rootTypes)) { + const typesDir = resolve(nodeModules, '@types') + mkdirSync(typesDir, { recursive: true }) + symlinkSync(rootTypes, resolve(typesDir, 'node'), 'dir') + } + + writeFileSync(resolve(tmp, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`) + writeFileSync(resolve(tmp, 'tsconfig.json'), `${JSON.stringify({ + compilerOptions: { + target: 'es2024', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + // Third-party SDK declarations can have their own lib-check noise under a + // symlinked temp install. The explicit scan above owns our regression: + // extensionless relative specifiers in built declarations. + skipLibCheck: true, + preserveSymlinks: true, + noEmit: true, + types: ['node'], + }, + include: ['index.ts'], + }, null, 2)}\n`) + + const imports = packages.flatMap(publicSpecifiers) + .map((specifier, index) => `import * as mod${index} from ${JSON.stringify(specifier)};\nvoid mod${index};`) + .join('\n') + writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) + + execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { + cwd: root, + stdio: 'pipe', + }) + console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`) +} catch (error: unknown) { + failed = true + const output = error as { stdout?: Buffer; stderr?: Buffer } + console.error('verify-node-next-types: NodeNext consumer typecheck failed.\n') + console.error(`${output.stdout?.toString() ?? ''}${output.stderr?.toString() ?? ''}`) +} finally { + rmSync(tmp, { recursive: true, force: true }) +} + +if (failed) process.exit(1) diff --git a/tsconfig.base.json b/tsconfig.base.json index f84c424b6d..3d6d1fc42a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,6 +10,8 @@ "incremental": true, "skipLibCheck": true, "esModuleInterop": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": false, "strict": true, "noUncheckedIndexedAccess": true, diff --git a/vendor/README.md b/vendor/README.md index dd55a9cd05..bf0f0b5a8c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -33,7 +33,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. 2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. -4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from explicit `.ts` / `.js` specifiers to extensionless specifiers so generated `.js` and `.d.ts` intermediates are extensionless and no declaration postprocess is needed. This includes `loader/src/config/isolate.ts` changing `declare module './entry.ts'` to `declare module './entry'`. +4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. ## Sync procedure diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 768ba52d6f..8b21c464b2 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -1,10 +1,10 @@ import { Dict } from 'cosmokit' -import { EventsService } from './events' -import { LoggerService } from './logger' -import { ReflectService } from './reflect' -import { InjectKey, RegistryService } from './registry' -import { getTraceable, symbols } from './utils' -import { Fiber } from './fiber' +import { EventsService } from './events.ts' +import { LoggerService } from './logger.ts' +import { ReflectService } from './reflect.ts' +import { InjectKey, RegistryService } from './registry.ts' +import { getTraceable, symbols } from './utils.ts' +import { Fiber } from './fiber.ts' /** * Public shape of a Cordis context. diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index f7dcf011f4..4461816537 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -1,7 +1,7 @@ import { defineProperty, Promisify } from 'cosmokit' -import { Context } from './context' -import { Fiber, FiberState } from './fiber' -import { DisposableList, symbols } from './utils' +import { Context } from './context.ts' +import { Fiber, FiberState } from './fiber.ts' +import { DisposableList, symbols } from './utils.ts' /** Return whether an event result should stop a bail-style dispatch. */ export function isBailed(value: any) { @@ -25,7 +25,7 @@ export type ThisType = F extends (this: infer T, ...args: any) => any ? T : n */ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' -declare module './context' { +declare module './context.ts' { export interface Context { /* eslint-disable max-len */ parallel(name: K, ...args: Parameters): Promise diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 840bc54352..fd472e7733 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -1,11 +1,11 @@ import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit' -import { Context } from './context' -import { Plugin } from './registry' -import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils' -import { Impl } from './reflect' +import { Context } from './context.ts' +import { Plugin } from './registry.ts' +import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' +import { Impl } from './reflect.ts' import { StandardSchemaV1 } from '@standard-schema/spec' -declare module './context' { +declare module './context.ts' { export interface Context extends Pick { fiber: Fiber } diff --git a/vendor/cordis/src/index.ts b/vendor/cordis/src/index.ts index 83d160395e..d0814213e0 100644 --- a/vendor/cordis/src/index.ts +++ b/vendor/cordis/src/index.ts @@ -1,14 +1,14 @@ /** Core context type and root context implementation. */ -export * from './context' +export * from './context.ts' /** Event bus, dispatch modes, and event augmentation types. */ -export * from './events' +export * from './events.ts' /** Plugin fiber lifecycle, effects, and config validation helpers. */ -export * from './fiber' +export * from './fiber.ts' /** Logger facade, logger service, message, exporter, and formatting types. */ -export * from './logger' +export * from './logger.ts' /** Plugin registry, dependency injection, and plugin entrypoint types. */ -export * from './registry' +export * from './registry.ts' /** Base service class and service lifecycle symbols. */ -export * from './service' +export * from './service.ts' /** Shared internal helpers used by context, services, and plugin fibers. */ -export * from './utils' +export * from './utils.ts' diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index f76ac2cdb7..a1e97c165a 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -1,9 +1,9 @@ import { defineProperty, hyphenate } from 'cosmokit' -import { Context } from './context' -import { Fiber } from './fiber' -import { createCallable, joinPrototype, symbols, Tracker } from './utils' +import { Context } from './context.ts' +import { Fiber } from './fiber.ts' +import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' -declare module './context' { +declare module './context.ts' { interface Intercept { logger: LoggerService.Intercept } diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index 4bc9fb44db..212ec4e779 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -1,9 +1,9 @@ import { defineProperty, Dict, isNullable } from 'cosmokit' -import { Context } from './context' -import { getTraceable, symbols, withProps } from './utils' -import { Fiber, FiberState } from './fiber' +import { Context } from './context.ts' +import { getTraceable, symbols, withProps } from './utils.ts' +import { Fiber, FiberState } from './fiber.ts' -declare module './context' { +declare module './context.ts' { interface Context { get(name: K, strict?: boolean): undefined | this[K] get(name: string, strict?: boolean): any diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index fae7712df9..9dfa10a06b 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -1,8 +1,8 @@ import { defineProperty, Dict } from 'cosmokit' import { StandardSchemaV1 } from '@standard-schema/spec' -import { Context } from './context' -import { Fiber } from './fiber' -import { buildOuterStack, DisposableList, symbols, withProps } from './utils' +import { Context } from './context.ts' +import { Fiber } from './fiber.ts' +import { buildOuterStack, DisposableList, symbols, withProps } from './utils.ts' function isApplicable(object: Plugin) { return object && typeof object === 'object' && typeof object.apply === 'function' @@ -140,7 +140,7 @@ type GetPluginConfig

= ? S : GetPluginParameters

[0] -declare module './context' { +declare module './context.ts' { export interface Context { inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index 4cc9f307f2..30895247c1 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -1,6 +1,6 @@ import { defineProperty } from 'cosmokit' -import { Context } from './context' -import { createCallable, joinPrototype, symbols, Tracker } from './utils' +import { Context } from './context.ts' +import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' /** * Base class for services that expose a named API on `ctx`. diff --git a/vendor/cordis/src/utils.ts b/vendor/cordis/src/utils.ts index 46dd962c6f..2fd499bd0c 100644 --- a/vendor/cordis/src/utils.ts +++ b/vendor/cordis/src/utils.ts @@ -1,5 +1,5 @@ import { defineProperty } from 'cosmokit' -import type { Context, Service } from '.' +import type { Context, Service } from './index.ts' /** Ordered collection of disposable values with O(1) deletion by value. */ export class DisposableList { diff --git a/vendor/cosmokit/src/array.ts b/vendor/cosmokit/src/array.ts index ccbc4b2752..18ed5e407f 100644 --- a/vendor/cosmokit/src/array.ts +++ b/vendor/cosmokit/src/array.ts @@ -1,4 +1,4 @@ -import { isNullable } from './misc' +import { isNullable } from './misc.ts' /** Return true when every item in `array2` is present in `array1`. */ export function contain(array1: readonly any[], array2: readonly any[]) { diff --git a/vendor/cosmokit/src/index.ts b/vendor/cosmokit/src/index.ts index 088e81c54f..9fe48de069 100644 --- a/vendor/cosmokit/src/index.ts +++ b/vendor/cosmokit/src/index.ts @@ -1,10 +1,10 @@ /** Array set and normalization helpers. */ -export * from './array' +export * from './array.ts' /** Runtime type, binary, clone, and equality helpers. */ -export * from './types' +export * from './types.ts' /** Shared utility types and object helpers. */ -export * from './misc' +export * from './misc.ts' /** String case, path, and property formatting helpers. */ -export * from './string' +export * from './string.ts' /** Time constants, parsing, and formatting helpers. */ -export * from './time' +export * from './time.ts' diff --git a/vendor/cosmokit/src/types.ts b/vendor/cosmokit/src/types.ts index b4d1e5bed8..499a46273a 100644 --- a/vendor/cosmokit/src/types.ts +++ b/vendor/cosmokit/src/types.ts @@ -1,4 +1,4 @@ -import { isNullable } from './misc' +import { isNullable } from './misc.ts' type GlobalConstructorNames = keyof { [K in keyof typeof globalThis as typeof globalThis[K] extends abstract new (...args: any) => any ? K : never]: K diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 8948625db6..ada10cc934 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -4,7 +4,7 @@ import { ModuleJob, ModuleLoader, ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' import { ChokidarOptions, FSWatcher, watch } from 'chokidar' import { relative, resolve } from 'node:path' -import { handleError } from './error' +import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 8acba39548..c2959fe61e 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,9 +1,9 @@ import { Context, Fiber, Inject } from 'cordis' import { deepEqual, isNullable } from 'cosmokit' -import { Loader } from '../index' -import { EntryGroup } from './group' -import { EntryTree } from './tree' -import { evaluate, interpolate } from './utils' +import { Loader } from '../index.ts' +import { EntryGroup } from './group.ts' +import { EntryTree } from './tree.ts' +import { evaluate, interpolate } from './utils.ts' /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index 5966d87eb8..f6ce0fe306 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -1,6 +1,6 @@ import { Context, Service } from 'cordis' -import { Entry, EntryOptions } from './entry' -import { EntryTree } from './tree' +import { Entry, EntryOptions } from './entry.ts' +import { EntryTree } from './tree.ts' /** Runtime owner for a list of child loader entries. */ export class EntryGroup { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 4b2f1df894..a2e930c4fb 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -1,8 +1,8 @@ import { Context } from 'cordis' import { Dict } from 'cosmokit' -import { Entry } from './entry' +import { Entry } from './entry.ts' -declare module './entry' { +declare module './entry.ts' { interface EntryOptions { intercept?: Dict | null isolate?: Dict | null diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 53f71220e1..6855884e11 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -1,7 +1,7 @@ import { composeError, Context } from 'cordis' import { Dict, isNonNullable } from 'cosmokit' -import { Entry, EntryOptions } from './entry' -import { EntryGroup } from './group' +import { Entry, EntryOptions } from './entry.ts' +import { EntryGroup } from './group.ts' /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ export abstract class EntryTree { diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 764f04f995..e18fc2ffa2 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,22 +1,22 @@ import { Context, Inject, Service } from 'cordis' import { defineProperty, Dict, isNullable } from 'cosmokit' -import { ModuleLoader } from './internal' -import { Entry, EntryOptions } from './config/entry' -import isolate from './config/isolate' -import { EntryTree } from './config/tree' +import { ModuleLoader } from './internal.ts' +import { Entry, EntryOptions } from './config/entry.ts' +import isolate from './config/isolate.ts' +import { EntryTree } from './config/tree.ts' /** Re-export entry node APIs. */ -export * from './config/entry' +export * from './config/entry.ts' /** Re-export nested entry group APIs. */ -export * from './config/group' +export * from './config/group.ts' /** Re-export service isolation helpers. */ -export * from './config/isolate' +export * from './config/isolate.ts' /** Re-export entry tree persistence APIs. */ -export * from './config/tree' +export * from './config/tree.ts' /** Re-export loader config expression helpers. */ -export * from './config/utils' +export * from './config/utils.ts' /** Re-export Node internal module loader compatibility types. */ -export * from './internal' +export * from './internal.ts' declare module 'cordis' { interface Events { diff --git a/vendor/logger-console/src/browser.ts b/vendor/logger-console/src/browser.ts index fb35366d14..b45a15e228 100644 --- a/vendor/logger-console/src/browser.ts +++ b/vendor/logger-console/src/browser.ts @@ -1,8 +1,8 @@ import { Message } from 'cordis' -import { ConsoleExporter as Base } from './shared' +import { ConsoleExporter as Base } from './shared.ts' /** Re-export shared console exporter config and base implementation. */ -export * from './shared' +export * from './shared.ts' /** Browser console exporter that dispatches to native console methods. */ export class ConsoleExporter extends Base { diff --git a/vendor/logger-console/src/index.ts b/vendor/logger-console/src/index.ts index 905287b1e8..d46ac6413f 100644 --- a/vendor/logger-console/src/index.ts +++ b/vendor/logger-console/src/index.ts @@ -1,10 +1,10 @@ import { Formatter } from 'cordis' import { inspect } from 'node:util' import supportsColor from 'supports-color' -import { ConsoleExporter as Base } from './shared' +import { ConsoleExporter as Base } from './shared.ts' /** Re-export shared console exporter config and base implementation. */ -export * from './shared' +export * from './shared.ts' const inspectFormatter: Formatter = (value, target) => { return inspect(value, { colors: !!target.colors, depth: Infinity, compact: true, breakLength: Infinity }) From b82c310db391df7ac60e1889983d5ccc24e747e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 06:47:20 +0800 Subject: [PATCH 22/40] Fix subagent in-process result scoping (Codex review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two merge-blocking bugs in the shared in-process run driver, both rooted in `readResult` scanning the whole child session and deriving the stop reason only from `turn/end`: - A pre-turn `cancel()` cleared the queued prompt before any `turn/end` was logged, so the run settled `error` instead of `aborted`, violating the `SubagentRun.cancel()` contract. The driver now tracks that a cancel was requested and maps the no-turn case to `aborted`. - A fork child whose own turn produced no `assistant/message` returned the SEEDED parent's last message as a `completed` success. `readResult` now scopes to the child's OWN events (after the seed prefix), so a message-less child yields empty output. Both fixes carry a regression test proven to go red on the pre-fix driver. Also: correct the `SubagentRun.id` / event-payload docs (it is the child AGENT id, not a session id — the backend mints distinct tokens); refresh the stale `coding-agent` welcome string (subagent is now a tool); and replace the stale `TODO(sub-agents)` "deferred" prose in the Agent interface, core.md, and architecture.md with an accurate pointer to the realized seam. --- docs/architecture.md | 2 +- docs/cordis-catalog/events-and-services.md | 28 ++++++------- docs/core-data-structures/core.md | 11 ++--- examples/coding-agent/cordis.yml | 2 +- packages/core/agent-loop/src/index.ts | 4 -- packages/core/agent/src/types.ts | 11 ++--- .../subagent-fork/tests/subagent-fork.spec.ts | 25 +++++++++++ .../subagent/subagent-spawn/src/in-process.ts | 42 ++++++++++++++----- .../tests/subagent-spawn.spec.ts | 16 +++++++ packages/subagent/subagent/src/index.ts | 4 +- packages/subagent/subagent/src/types.ts | 2 +- 11 files changed, 104 insertions(+), 43 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..612e73ddce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` -**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. +**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. ### Loop lifecycle (session / turn / step) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 36edfae505..e0fe1cd6c2 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 117d1e3606..49c1559c40 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -273,11 +273,12 @@ interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. } ``` diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index c8371d3cba..136031062a 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -44,7 +44,7 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).' + welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).' systemPrompt: | You are coding-agent, a CLI coding assistant. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3179674366..e5393ed0aa 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -121,10 +121,6 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. - * - * TODO(sub-agents): spawn/fork land here — accept a parent agent reference; - * fork seeds the new Session with the parent's event log, spawn starts - * fresh; the child is returned as a regular Agent handle. */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 6d27bf7256..efe392155c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -118,11 +118,12 @@ export interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. } declare module 'cordis' { diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index d550101cb9..96cdd55141 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -10,11 +10,16 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] +/** A bare `stop` finish that streams no content → the turn ends `completed` + * with NO `assistant/message` of its own. */ +const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }] + /** * Drives the REAL fork backend with a real loop + scripted mock MODEL + the * real dsh-invariants plugin. The invariants plugin re-replays a seeded child @@ -132,6 +137,26 @@ describe('dsh-subagent-fork', () => { await run.dispose() }) + it('does NOT return the seeded parent output when the child produces no message of its own', async () => { + // Regression: readResult must scope to the child's OWN events (after the + // seed). The parent completes a turn with a distinctive assistant message, + // then the fork child's own turn finishes with a bare `stop` and NO + // assistant/message. Scanning the whole (seeded) log would return the + // parent's "parent stale" message with stopReason 'completed'; scoped to the + // child's own events the output is empty. + const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + // The child completed its own (empty) turn — completed, but with NO output + // borrowed from the seeded parent prefix. + expect(result.stopReason).toBe('completed') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('advertises depthLimit but not outputSchema/toolFilter', async () => { const { ctx } = await setup([]) expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts index 4b40bb667d..3b04b55c86 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -99,6 +99,11 @@ export function startInProcessRun( } const childId = AgentId(randomUUID()) + // The child's OWN events begin after the seed (fork seeds the parent's + // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this + // boundary so a child that produces no message of its own never returns the + // SEEDED parent's last assistant message as its result. + const seedLength = options.seed?.length ?? 0 const parentHeader = request.parent.session.header // Inherit the parent's model by default (a child with no model cannot run); // an explicit `request.agentOptions.model` overrides it. The parent's @@ -124,14 +129,23 @@ export function startInProcessRun( // Bridge the request's abort signal to the child (the consumer also bridges // its own exec.signal, but a backend-level bridge keeps the contract local). - const onAbort = (): void => { child.cancel('subagent cancelled') } + // `cancelled` records that a cancel was requested at all, so the pre-turn + // cancel window — where the child clears the queued prompt before any + // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) + // rather than falling through to the no-turn `error` mapping. + let cancelled = false + const requestCancel = (reason: string): void => { + cancelled = true + child.cancel(reason) + } + const onAbort = (): void => { requestCancel('subagent cancelled') } request.signal?.addEventListener('abort', onAbort, { once: true }) const result: Promise = (async () => { try { child.send(request.prompt) await child.whenIdle() - return readResult(child) + return readResult(child, seedLength, cancelled) } finally { request.signal?.removeEventListener('abort', onAbort) } @@ -141,7 +155,7 @@ export function startInProcessRun( id: childId, result, cancel(reason?: string): void { - child.cancel(reason ?? 'subagent cancelled') + requestCancel(reason ?? 'subagent cancelled') }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) @@ -151,14 +165,22 @@ export function startInProcessRun( } /** - * Read a settled child's terminal result from its session log: the last - * `assistant/message` content (deep-cloned — the log is frozen) and the last - * `turn/end` reason mapped to a {@link SubagentStopReason}. + * Read a settled child's terminal result from its session log, scoped to the + * child's OWN events (everything at or after `seedLength` — fork seeds the + * parent's completed-turn prefix, so a child that produced no message of its + * own must NOT return the seeded parent's last assistant message). The output + * is the child's last `assistant/message` content (deep-cloned — the log is + * frozen); the stop reason is the child's last `turn/end` reason mapped to a + * {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was + * logged (a cancel landed in the pre-turn window, before any turn ran), the + * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than + * the generic no-turn `error`. */ -function readResult(child: Agent): SubagentResult { - const events = child.session.events - const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') - const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') +function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult { + const own = child.session.events.slice(seedLength) + const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') + const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] + if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' } return { output, stopReason: toStopReason(lastEnd?.data.reason) } } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 830aa81ec8..4cbf1ca9d3 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -128,6 +128,22 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('cancelling BEFORE the child turn starts settles aborted, not error', async () => { + // Regression: a cancel landing in the pre-turn window clears the queued + // prompt before any `turn/end` is logged. Deriving the stop reason from + // `turn/end` alone then mis-maps the no-turn case to `error`; the run must + // honor the cancel contract and settle `aborted`. The cancel is synchronous + // (same tick as start, before the loop's queued-wait continuation runs), so + // the turn is dropped and the empty script is never consumed. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + run.cancel('early') + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index c7d954f09c..356ad60a00 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -70,7 +70,7 @@ declare module 'cordis' { export interface SubagentRunInfo { /** The provider that started the run. */ provider: string - /** The child agent/session id. */ + /** The child agent's id. */ id: AgentId } @@ -78,7 +78,7 @@ export interface SubagentRunInfo { export interface SubagentRunEndInfo { /** The provider that ran it. */ provider: string - /** The child agent/session id. */ + /** The child agent's id. */ id: AgentId /** The terminal stop reason. */ stopReason: SubagentResult['stopReason'] diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0e04acb317..fb60d5667c 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -122,7 +122,7 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (also its session id token, for correlation). */ + /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */ readonly id: AgentId /** * Resolves with the child's terminal {@link SubagentResult} when the run From 9c1048f2b599ce3e3e8fa127a45237715e61a138 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:32:09 +0800 Subject: [PATCH 23/40] Honor an already-aborted request signal in the subagent driver (Codex review round 2) A request signal aborted BEFORE the run starts never fires an `abort` event (`addEventListener` only fires on the transition), so the backend-level bridge missed it and ran the child to `completed`. The driver now checks `request.signal?.aborted` at the top of the result path and settles `aborted` without running the child. Regression test proven red on the pre-fix code. Also refresh two stale RFC prose blocks the round-1 fix left behind: the subagent RFC's Problem statement (cited the removed `TODO(sub-agents)` markers and claimed nothing existed yet) and the unify-id RFC's fork/spawn risk bullet (described the seam as "explicitly deferred" via `AgentLoop.create`'s old TODO), now pointing at the realized seam. --- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 2 +- .../subagent/subagent-spawn/src/in-process.ts | 5 +++++ .../subagent-spawn/tests/subagent-spawn.spec.ts | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index 02c4fa36b4..d99637b550 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -6,7 +6,7 @@ Status: proposed ## Problem -The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent is sketched in two `TODO(sub-agents)` markers ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. No service, vocabulary, or implementation exists yet. +The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam (see the implementation-status banner above for what has landed); the design below is the proposal it was argued from, when no service, vocabulary, or implementation yet existed. The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 19bde62d01..2455981ba8 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -46,7 +46,7 @@ The genuine risks of collapsing the two ids into one (the case AGAINST this prop - **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes. -- **Sub-agents / fork / spawn (an explicitly deferred seam) may WANT a stable actor id across forked sessions.** `AgentLoop.create`'s `TODO(sub-agents)` envisions a child agent seeded from a parent's event log. If the design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. +- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.) - **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong. diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts index 3b04b55c86..e121611697 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -143,6 +143,11 @@ export function startInProcessRun( const result: Promise = (async () => { try { + // A signal already aborted BEFORE the run starts never fires an `abort` + // event (`addEventListener` only fires on the transition), so the listener + // above won't catch it — settle `aborted` without running the child rather + // than completing an already-cancelled request. + if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } child.send(request.prompt) await child.whenIdle() return readResult(child, seedLength, cancelled) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 4cbf1ca9d3..c57819d86a 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -128,6 +128,22 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => { + // Regression: a signal aborted BEFORE the run starts never fires an `abort` + // event, so the listener can't catch it. The driver must check the + // already-aborted case up front and settle `aborted` without running the + // child — otherwise an already-cancelled request runs to `completed`. The + // empty script proves the child's model is never called. + const controller = new AbortController() + controller.abort() + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('cancelling BEFORE the child turn starts settles aborted, not error', async () => { // Regression: a cancel landing in the pre-turn window clears the queued // prompt before any `turn/end` is logged. Deriving the stop reason from From e68496fd7981d5da6a3dd44d9ca5c9fc18bd8398 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:39:36 +0800 Subject: [PATCH 24/40] Add per-session snapshot replay for nested agents (PR2.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot tier was built single-session: dsh-llm-replay served calls from one global positional cursor, and the harness harvested one session log. A subagent runs as a second agent with its own session, so a parent→child scenario could neither replay deterministically nor harvest the child's log. This resolves the TODO(subagent-snapshots) deferral from the subagent RFC. - Stamp the calling session id onto the model request: GenerateOptions.sessionId (typed Branded<'SessionId'> to avoid the dsh-llm↔dsh-session cycle), set by the agent loop from agent.session.id. Adapters ignore it; an llm/stream listener routes by it. - Key replay per session: dsh-llm-replay loads the parent log plus one per child (childFiles / $DSH_SNAPSHOT_CHILD_FILES), derives a script per recorded session, and binds each live (freshly-random) session to a recorded script by first-call order — parent first (earliest createdAt, first to stream). Keys by WHO calls, so it survives a future concurrent/backgrounded subagent; a global cursor would not. An unrecorded extra session fails loud. - Harvest every log: the harness collects all .jsonl across cwd buckets, ordered primary-first (top-level, then children by createdAt), and RunResult exposes the plural sessionLogs. The spec writes each back on record (session.jsonl + session..jsonl) and diffs each against its fixture on replay. - Wire the subagent seam + spawn + fork + tool into the acp-agent example (both cordis configs) and add two nested scenarios recorded against the real API: subagent-spawn (parent + 1 child) and subagent-multi (parent + 2 children, 3 sessions). Both replay keyless in the default gate. A new RFC documents the design (docs/rfc/implemented/testing/). Single-session replay is unchanged (a call with no sessionId is one anonymous primary session). TODO follow-up: a dedicated branded-ids package could own the SessionId brand and dissolve the cross-package cycle note; out of scope for this testing PR. --- docs/core-data-structures/core.md | 14 + docs/rfc/README.md | 1 + .../2026-06-22-subagent-snapshot-replay.md | 52 ++++ .../2026-06-21-subagent-capability-seam.md | 2 +- examples/acp-agent/cordis.snapshot.yml | 36 ++- examples/acp-agent/cordis.yml | 38 ++- examples/acp-agent/tests/acp.snapshot.ts | 77 ++++-- examples/acp-agent/tests/snapshot-harness.ts | 87 +++++-- .../tests/snapshots/subagent-multi/input.json | 7 + .../snapshots/subagent-multi/session.1.jsonl | 35 +++ .../snapshots/subagent-multi/session.2.jsonl | 33 +++ .../snapshots/subagent-multi/session.jsonl | 213 ++++++++++++++++ .../subagent-multi/stdout.golden.jsonl | 115 +++++++++ .../tests/snapshots/subagent-spawn/input.json | 7 + .../snapshots/subagent-spawn/session.1.jsonl | 35 +++ .../snapshots/subagent-spawn/session.jsonl | 142 +++++++++++ .../subagent-spawn/stdout.golden.jsonl | 89 +++++++ packages/core/agent-loop/src/loop.ts | 1 + packages/llm/llm/src/types.ts | 15 ++ packages/support/llm-replay/README.md | 23 +- packages/support/llm-replay/src/index.ts | 219 +++++++++++++--- .../llm-replay/tests/llm-replay.spec.ts | 239 +++++++++++++++++- 22 files changed, 1392 insertions(+), 88 deletions(-) create mode 100644 docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-multi/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-spawn/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 49c1559c40..ad884aeff6 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -140,6 +140,20 @@ interface GenerateOptions { */ stop?: string[] signal?: AbortSignal + /** + * The id of the session this request belongs to — stamped by the agent loop + * from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener + * route a call by WHICH session issued it (the replay adapter keys its per-call + * cursor by session, so a parent and its in-process subagent — each with its + * own session on one context — replay from their own recorded scripts). + * + * Typed as `Branded<'SessionId'>` rather than importing `SessionId` from + * `dsh-session`: that package imports `Message` from here, so importing its + * `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a + * real session id assigns with no cast. (A future ids package could own the + * brand and dissolve this note.) + */ + sessionId?: Branded<'SessionId'> } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 41bb1c537f..87b6456ab8 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -139,6 +139,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | | [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | ## Rejected diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md new file mode 100644 index 0000000000..92a324104e --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -0,0 +1,52 @@ +# RFC: Per-session snapshot replay for nested agents + +Status: implemented + +## Problem + +The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subprocess, replays a recorded session through [`dsh-llm-replay`](../../../../packages/support/llm-replay), and diffs the normalized stdout transcript + re-persisted session log against committed goldens. It is the only tier that exercises the full editor-facing transcript end to end. + +It was built for ONE session per process, and that assumption is wired into two places: + +- **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa). +- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log in the same cwd bucket, so the child's transcript was silently dropped. + +This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam RFC](../../proposed/feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This RFC is that stacked follow-up. + +## Decision + +Replay is keyed **per calling session**, and the harness harvests **every** session log. + +### 1. The calling session id rides on the model request + +`GenerateOptions` gains an optional `sessionId`, stamped by the agent loop from `agent.session.id` at request-assembly time (where the session is already in scope). Adapters ignore it; it exists so an `llm/stream` listener can route a call by WHICH session issued it. It is typed `Branded<'SessionId'>` (from `dsh-brand`) rather than importing `SessionId` from `dsh-session` — that package imports `Message` from `dsh-llm`, so importing its id back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a real id assigns with no cast. (A future dedicated ids package could own the brand and dissolve the note; tracked separately — it touches every id import and does not belong in this testing PR.) + +### 2. Replay binds live sessions to recorded scripts by first-call order + +A nested scenario records more than one log: the parent (`session.jsonl`) plus one per subagent child (`session.1.jsonl`, …). `dsh-llm-replay` loads them all, derives one script per recorded session, and orders the scripts by header `createdAt` (the parent is created before its children). + +Live session ids are freshly random every run and never equal the recorded ones, so a live session cannot bind to a script by id equality. Instead it binds by **first-call order**: the first live session to make any model call claims the first ordered script (the parent — earliest `createdAt`, and necessarily the first to stream, because it must run a turn before it can delegate), the next new live session claims the next script, and so on. Each session then advances its own cursor independently. + +This keys by WHO calls, not by global call order — so it stays correct even if subagents ever run concurrently or in the background (a global cursor would interleave them). A call carrying no `sessionId` (a direct unit-test `stream()`) is treated as one anonymous session bound to the primary script, so the single-session path is byte-for-byte the old behavior. More distinct live sessions than recorded scripts is a fail-loud error (an unrecorded subagent appeared), never a silent mis-route. + +The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for today's synchronous cut but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not. + +### 3. The harness harvests every log, primary-first + +`harvestSessionLogs` collects every `.jsonl` across every cwd bucket under the sessions root (the JSONL backend puts a parent and its same-cwd child in the same bucket), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session..jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed. + +### 4. Scenarios + +Two nested scenarios were added and recorded against the real API: + +- **`subagent-spawn`** — the parent delegates one subtask via the `subagent` tool to a fresh spawn child (2 sessions). +- **`subagent-multi`** — the parent delegates two subtasks, each to its own spawn child (3 sessions), stressing the per-session keying with three concurrent scripts and the `createdAt` ordering of two children under one parent. + +Both replay keyless in the default gate. + +## Consequences + +- The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape. +- `GenerateOptions.sessionId` is a small, honest core-seam addition useful beyond replay (telemetry, request routing). +- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The fork backend is loaded in the example and exercised by PR2's unit tests; a mixed spawn+fork snapshot would need a second tool instance bound to `fork` (pure config) and is a trivial future addition, not a gap in the keying — the keying routes by session, not by backend. +- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in the PR3 plan. diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index d99637b550..71a277ff5b 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -70,4 +70,4 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. -- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`, whose dispatch is a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and whose harness harvests a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needs per-session-keyed replay (or a call-ordered merge of both logs, sound because subagent execution is strictly nested/non-concurrent — the parent blocks on the child) plus harvest-all-logs and plural-session-id plumbing in the harness. This is self-contained infrastructure orthogonal to the backends, so it lands as a **dedicated stacked follow-up** rather than in the in-process-backends PR. Until it lands, in-process subagents are covered by real-loop unit tests (a parent driving a fork AND a spawn child) and a with-key e2e (a parent delegating to a child that writes a file), not by the snapshot transcript tier. Tracked by `TODO(subagent-snapshots)`. +- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`. It was built single-session: a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and a harness that harvested a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needed per-session-keyed replay plus harvest-all-logs and plural-session-id plumbing — self-contained infrastructure orthogonal to the backends, scheduled as a dedicated stacked follow-up rather than folded into the in-process-backends PR. That follow-up has **landed**: see [Per-session snapshot replay for nested agents](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). Replay now keys each call by its calling session (`GenerateOptions.sessionId`) and binds live sessions to recorded scripts by first-call order; the harness harvests every log; and two nested scenarios (`subagent-spawn`, `subagent-multi`) replay keyless in the default gate. In-process subagents remain covered by real-loop unit tests and a with-key e2e in addition to the snapshot tier. diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index b57920668a..5bee10f2a7 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -31,9 +31,33 @@ systemPrompt: | You are a coding assistant driven over the Agent Client Protocol. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit with - sed or a rewrite. Each bash call runs in a fresh shell — pass workdir - instead of cd. Check the [exit code: N] marker; verify your work. Keep - answers brief and factual. + Your tools are bash (plus bash_output/bash_kill for background tasks) + and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd. Check the [exit code: N] marker; + verify your work. Keep answers brief and factual. + + Use the subagent tool to delegate a focused, self-contained subtask to + a fresh child agent (it works in its own context and returns only its + final result) — give it a complete, standalone instruction. + +# The subagent seam + both in-process backends + the model-facing `subagent` +# tool — identical to cordis.yml's wiring (only the LLM backend differs above). +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index e456e8ff05..e00e868dce 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -40,9 +40,35 @@ systemPrompt: | You are a coding assistant driven over the Agent Client Protocol. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit with - sed or a rewrite. Each bash call runs in a fresh shell — pass workdir - instead of cd. Check the [exit code: N] marker; verify your work. Keep - answers brief and factual. + Your tools are bash (plus bash_output/bash_kill for background tasks) + and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd. Check the [exit code: N] marker; + verify your work. Keep answers brief and factual. + + Use the subagent tool to delegate a focused, self-contained subtask to + a fresh child agent (it works in its own context and returns only its + final result) — give it a complete, standalone instruction. + +# The subagent seam + both in-process backends + the model-facing `subagent` +# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The +# tool is bound to the `spawn` backend (a fresh child); the `fork` backend is +# loaded too so a multi-child scenario can exercise both transports. +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index be6aabada0..ffdea0f94e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { type InputScript, runScenario } from './snapshot-harness.ts' +import { type HarvestedLog, type InputScript, runScenario } from './snapshot-harness.ts' import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize.ts' /** @@ -37,6 +37,14 @@ interface Scenario { * coaxed into deterministically) are NEVER re-recorded. */ recorded: boolean + /** + * How many SUBAGENT child sessions this scenario records beyond the top-level + * one (0 for a single-session scenario). Each child rides in a sibling fixture + * `session..jsonl` (1-based); replay forwards them to `dsh-llm-replay` so + * each child session replays from its own script, and record mode writes the + * harvested child logs back to those files. Defaults to 0. + */ + childSessions?: number } const SCENARIOS: Scenario[] = [ @@ -48,8 +56,15 @@ const SCENARIOS: Scenario[] = [ { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false }, + { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, ] +/** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */ +function childFixturePaths(dir: string, childSessions: number): string[] { + return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`)) +} + /** * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own * header line (`{ type: 'session', id, cwd }`). A committed fixture carries the @@ -81,42 +96,59 @@ for (const scenario of SCENARIOS) { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') + const childSessions = scenario.childSessions ?? 0 const result = await runScenario(input, { mode: RECORDING ? 'record' : 'replay', fixtureFile: join(dir, 'session.jsonl'), ...existsSync(overrideFile) ? { overrideFile } : {}, + // In REPLAY, forward the recorded child fixtures so each subagent session + // replays from its own script. In RECORD they are harvested, not read. + ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, }) + // Scrub every volatile id the run produced: the ACP server-issued session + // id plus every harvested log's recorded id (a subagent child id never + // surfaces over ACP, but it appears in the child's own log header). The + // normalizer's UUID catch-all covers any we don't enumerate. const ctx: NormalizeContext = { - sessionIds: result.sessionId !== undefined ? [result.sessionId] : [], + sessionIds: [ + ...result.sessionId !== undefined ? [result.sessionId] : [], + ...result.sessionLogs.map(l => l.id), + ], cwd: result.cwd, } - // RECORD mode (recorded scenarios only): persist the freshly-harvested log - // back to the scenario's session.jsonl fixture. `--update` refreshes the - // Vitest goldens but NOT this fixture, so write it here. + // RECORD mode (recorded model scenarios only): persist the freshly-harvested + // logs back to their fixtures — the primary to session.jsonl, each child to + // session..jsonl in harvest order. `--update` refreshes the Vitest + // goldens but NOT these fixtures, so write them here. if (RECORDING && scenario.recorded && scenario.hasModelTurn) { - expect(result.sessionLog, 'record produced no session log to harvest').toBeDefined() - await writeFile(join(dir, 'session.jsonl'), result.sessionLog as string) + expect(result.sessionLogs.length, 'record produced no session log to harvest').toBeGreaterThan(0) + expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`) + .toBe(childSessions + 1) + await writeFile(join(dir, 'session.jsonl'), (result.sessionLogs[0] as HarvestedLog).content) + for (let i = 1; i < result.sessionLogs.length; i++) { + await writeFile(join(dir, `session.${i}.jsonl`), (result.sessionLogs[i] as HarvestedLog).content) + } } await expect(normalizeStdout(result.rawStdout, ctx)) .toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl')) if (scenario.hasModelTurn) { - expect(result.sessionLog, 'a model scenario must persist a session log').toBeDefined() - // Compare the replay run's persisted log against the `session.jsonl` - // fixture — there is no separate session golden. Both sides pass through - // normalizeSessionLog so the comparison is on normalized form: the - // fixture is raw-harvested (its own real session id / cwd / timestamps), - // the replay output has fresh ones, and each is scrubbed against ITS OWN - // volatile values. The fixture's are read from its header line (a - // committed file cannot share the live run's ctx), so the stale recorded - // cwd/id are scrubbed too, not left to leak past the run's `ctx`. - const fixture = await readFile(join(dir, 'session.jsonl'), 'utf8') - expect(normalizeSessionLog(result.sessionLog as string, ctx)) - .toEqual(normalizeSessionLog(fixture, fixtureContext(fixture))) + // The harvested logs (primary-first) must match their committed fixtures + // 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS + // OWN volatile values — the live run's via `ctx`, the committed fixture's + // via its own header (a committed file cannot share the live run's ids). + expect(result.sessionLogs.length, 'a model scenario must persist a session log').toBe(childSessions + 1) + const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] + for (let i = 0; i < fixtureFiles.length; i++) { + const harvested = (result.sessionLogs[i] as HarvestedLog).content + const fixture = await readFile(join(dir, fixtureFiles[i] as string), 'utf8') + expect(normalizeSessionLog(harvested, ctx), `${fixtureFiles[i]} mismatch`) + .toEqual(normalizeSessionLog(fixture, fixtureContext(fixture))) + } } }) }) @@ -144,7 +176,7 @@ describe('snapshot fixtures', () => { // doubles as the expected-log artifact the run is diffed against. An authored // (non-`recorded`) model scenario additionally ships a `replay.override.json` // sidecar for the throw/hang cases a derived script cannot express. - for (const { name, hasModelTurn, recorded } of SCENARIOS) { + for (const { name, hasModelTurn, recorded, childSessions } of SCENARIOS) { const dir = join(SNAPSHOTS_DIR, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) @@ -152,6 +184,11 @@ describe('snapshot fixtures', () => { if (hasModelTurn && !recorded) { expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(true) } + // A nested-agent scenario ships one child fixture per recorded subagent + // session (`session.1.jsonl` …), the replay source for that child session. + for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) { + expect(existsSync(childFixture), childFixture).toBe(true) + } } }) }) diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index 7545768b1d..80847f08dd 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -17,7 +17,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, delimiter } from 'node:path' import { fileURLToPath } from 'node:url' import { Readable, Writable } from 'node:stream' import { @@ -71,7 +71,19 @@ export interface InputScript { steps: InputStep[] } -/** The result of running a scenario: raw stdout + the harvested session log. */ +/** One harvested session log plus the identifying facts off its header line. */ +export interface HarvestedLog { + /** The recorded session id (header `id`). */ + id: string + /** Session creation time (header `createdAt`) — the child-ordering key. */ + createdAt: number + /** The parent session id, if this log is a subagent child (header `parentSession`). */ + parentSession?: string + /** The full `.jsonl` file content. */ + content: string +} + +/** The result of running a scenario: raw stdout + the harvested session log(s). */ export interface RunResult { /** Raw stdout bytes (decoded utf8), every newline-delimited JSON-RPC frame. */ rawStdout: string @@ -81,8 +93,13 @@ export interface RunResult { sessionId?: string /** The temp cwd the session ran in (the bash workspace). */ cwd: string - /** The persisted session log's content, if one was produced. */ - sessionLog?: string + /** + * Every persisted session log harvested after the run, ordered primary-first: + * the top-level (parent) session — the one with no `parentSession` — then each + * subagent child by ascending `createdAt`. A single-session scenario harvests + * exactly one; a nested-agent scenario harvests the parent plus one per child. + */ + sessionLogs: HarvestedLog[] } interface RunOptions { @@ -92,6 +109,14 @@ interface RunOptions { fixtureFile: string /** Optional sidecar override path (replay). */ overrideFile?: string + /** + * Recorded SUBAGENT child-session fixture paths (replay). A nested-agent + * scenario ships one per child (`session.1.jsonl`, …); the harness forwards + * them to `dsh-llm-replay` via `$DSH_SNAPSHOT_CHILD_FILES` so each child + * session replays from its own recorded script. Empty for single-session + * scenarios. Ignored in record mode (children are harvested, not replayed). + */ + childFiles?: string[] /** * Optional `/workspace/` directory whose contents are copied into * the temp cwd BEFORE the run — the standard way to seed files the agent @@ -114,7 +139,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // never leaks them (the "e2e tests own their resources" rule). let child: ChildProcessWithoutNullStreams | undefined let sessionId: string | undefined - let sessionLog: string | undefined + let sessionLogs: HarvestedLog[] = [] const rawBuffers: Buffer[] = [] const stderrChunks: string[] = [] try { @@ -131,6 +156,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, + ...opts.childFiles !== undefined && opts.childFiles.length > 0 + ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } + : {}, } child = spawn( @@ -189,9 +217,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // persistence) and exits. Then await exit so the harvested log is complete. child.stdin.end() await waitForExit(child) - // Harvest the persisted log (if any) while the temp dirs still exist. - const sessionLogPath = await findSessionLog(sessionsRoot) - if (sessionLogPath !== undefined) sessionLog = await readFile(sessionLogPath, 'utf8') + // Harvest EVERY persisted log (parent + any subagent children) while the + // temp dirs still exist, ordered primary-first. + sessionLogs = await harvestSessionLogs(sessionsRoot) } finally { // Failure-safe teardown: kill a still-running child and drop the temp dirs // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a @@ -209,7 +237,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise stderr: stderrChunks.join(''), cwd, ...sessionId !== undefined ? { sessionId } : {}, - ...sessionLog !== undefined ? { sessionLog } : {}, + sessionLogs, } } @@ -300,14 +328,25 @@ function waitForExit(child: ChildProcessWithoutNullStreams): Promise { return new Promise(resolve => child.once('exit', () => { resolve() })) } -/** Find the single produced `.jsonl` session log under a sessions root, if any. */ -async function findSessionLog(root: string): Promise { +/** + * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each + * header line, and return them ordered primary-first: the top-level session (no + * `parentSession`) leads, then each subagent child by ascending `createdAt`. + * + * The JSONL backend lays sessions out as `//.jsonl` + * (one bucket per cwd), so a parent and its same-cwd in-process child land in + * the SAME bucket — collecting all files across all buckets catches both (the + * old first-match short-circuit silently dropped the child). Returns `[]` if no + * log was produced (a no-session scenario). + */ +async function harvestSessionLogs(root: string): Promise { let cwdDirs: string[] try { cwdDirs = await readdir(root) } catch { - return undefined + return [] } + const logs: HarvestedLog[] = [] for (const dir of cwdDirs) { const sub = join(root, dir) let files: string[] @@ -316,8 +355,26 @@ async function findSessionLog(root: string): Promise { } catch { continue } - const jsonl = files.find(f => f.endsWith('.jsonl')) - if (jsonl !== undefined) return join(sub, jsonl) + for (const f of files) { + if (!f.endsWith('.jsonl')) continue + const content = await readFile(join(sub, f), 'utf8') + const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}' + const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown } + logs.push({ + id: typeof header.id === 'string' ? header.id : '', + createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0, + ...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {}, + content, + }) + } } - return undefined + // Primary (no parentSession) first, then children by ascending createdAt. A + // scenario has exactly one top-level session; ties among children fall back to + // recorded id for a stable order. + logs.sort((a, b) => { + const ap = a.parentSession === undefined ? 0 : 1 + const bp = b.parentSession === undefined ? 0 : 1 + return ap - bp || a.createdAt - b.createdAt || a.id.localeCompare(b.id) + }) + return logs } diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/input.json b/examples/acp-agent/tests/snapshots/subagent-multi/input.json new file mode 100644 index 0000000000..d497fd737a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl new file mode 100644 index 0000000000..13ff222aae --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"dba897b9-c416-4b56-928c-75d12c3e6b32","createdAt":1782087750369,"cwd":"/tmp/acp-snap-cwd-v6PaeC","parentSession":"2a50c62e-1d77-4b0e-bfab-3a9285e3aa32"} +{"type":"turn/start","seq":0,"time":1782087750369,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087750369,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782087750370,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087750967,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087750967,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087751058,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":7,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":16,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":17,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":18,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":25,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":26,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":27,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":28,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":29,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":68,"outputTokens":23,"cacheReadTokens":896,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":30,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1782087751198,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"usage":{"inputTokens":68,"outputTokens":23,"cacheReadTokens":896,"reasoningTokens":19}}} +{"type":"step/end","seq":32,"time":1782087751198,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1782087751198,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl new file mode 100644 index 0000000000..a239b67cc3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"52bb4e53-1cf4-4680-b954-4ad941a9e986","createdAt":1782087752261,"cwd":"/tmp/acp-snap-cwd-v6PaeC","parentSession":"2a50c62e-1d77-4b0e-bfab-3a9285e3aa32"} +{"type":"turn/start","seq":0,"time":1782087752262,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087752262,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782087752262,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087752714,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087752714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087752857,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087752875,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":7,"time":1782087752909,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087752909,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087752910,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782087752910,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782087752910,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782087752941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782087752941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":16,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":17,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1782087752974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1782087752975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":20,"time":1782087752975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":21,"time":1782087752975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":24,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":25,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":26,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":27,"time":1782087753005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":67,"outputTokens":21,"cacheReadTokens":896,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":28,"time":1782087753005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1782087753005,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"usage":{"inputTokens":67,"outputTokens":21,"cacheReadTokens":896,"reasoningTokens":18}}} +{"type":"step/end","seq":30,"time":1782087753005,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":31,"time":1782087753005,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl new file mode 100644 index 0000000000..e42c624a97 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -0,0 +1,213 @@ +{"type":"session","version":0,"id":"2a50c62e-1d77-4b0e-bfab-3a9285e3aa32","createdAt":1782087748790,"cwd":"/tmp/acp-snap-cwd-v6PaeC"} +{"type":"turn/start","seq":0,"time":1782087748793,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087748794,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782087748794,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087749465,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087749465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087749560,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087749588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":10,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1782087749617,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":12,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":13,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":15,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":16,"time":1782087749643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":17,"time":1782087749644,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":18,"time":1782087749644,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":19,"time":1782087749672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} +{"type":"assistant/chunk","seq":20,"time":1782087749673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1782087749673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":22,"time":1782087749702,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} +{"type":"assistant/chunk","seq":23,"time":1782087749703,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":24,"time":1782087749703,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":25,"time":1782087749731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":26,"time":1782087749731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":27,"time":1782087749732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":29,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":30,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":31,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":32,"time":1782087749759,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":33,"time":1782087749786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":34,"time":1782087749787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":35,"time":1782087749814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":36,"time":1782087749814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":37,"time":1782087749843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} +{"type":"assistant/chunk","seq":38,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":39,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":40,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":41,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":42,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":43,"time":1782087749872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":44,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":45,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":46,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":47,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":48,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":49,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":50,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":51,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":52,"time":1782087749902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":53,"time":1782087749902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":54,"time":1782087749930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":55,"time":1782087749930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":56,"time":1782087749930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":57,"time":1782087749931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":58,"time":1782087749931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":59,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":60,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":61,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":62,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":63,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":64,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":65,"time":1782087749986,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":66,"time":1782087750072,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":67,"time":1782087750072,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":68,"time":1782087750075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":69,"time":1782087750075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1782087750075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":71,"time":1782087750102,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1782087750102,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":73,"time":1782087750103,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1782087750103,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"First"}}} +{"type":"assistant/chunk","seq":75,"time":1782087750129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" subt"}}} +{"type":"assistant/chunk","seq":76,"time":1782087750129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":77,"time":1782087750130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":78,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":79,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":80,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":81,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":82,"time":1782087750189,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":83,"time":1782087750190,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":85,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":86,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":87,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":88,"time":1782087750247,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":90,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":91,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":92,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":93,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":94,"time":1782087750275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":95,"time":1782087750275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":96,"time":1782087750275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":97,"time":1782087750276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":98,"time":1782087750276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":99,"time":1782087750276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":100,"time":1782087750304,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":101,"time":1782087750305,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1782087750305,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":103,"time":1782087750365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, once at a time. First subtask: reply with \"ALPHA\". After that returns, second subtask: reply with \"BETA\". Then I reply with \"PARENT_DONE\". Let me start with the first subagent call."}}}} +{"type":"assistant/chunk","seq":104,"time":1782087750366,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","arguments":"{\"description\": \"First subtask: ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":105,"time":1782087750366,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1189,"outputTokens":139,"cacheReadTokens":0,"reasoningTokens":62}}}} +{"type":"assistant/chunk","seq":106,"time":1782087750366,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":107,"time":1782087750368,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, once at a time. First subtask: reply with \"ALPHA\". After that returns, second subtask: reply with \"BETA\". Then I reply with \"PARENT_DONE\". Let me start with the first subagent call."},{"type":"tool-call","id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","arguments":"{\"description\": \"First subtask: ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"usage":{"inputTokens":1189,"outputTokens":139,"cacheReadTokens":0,"reasoningTokens":62}}} +{"type":"tool/call","seq":108,"time":1782087750368,"data":{"turn":1,"step":1,"callId":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","arguments":"{\"description\": \"First subtask: ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":109,"time":1782087751204,"data":{"turn":1,"step":1,"callId":"call_00_T91HrbiohZjyqZ7biX4s4408","content":[{"type":"text","text":"ALPHA"}],"isError":false}} +{"type":"step/end","seq":110,"time":1782087751204,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":111,"time":1782087751204,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":112,"time":1782087751674,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":113,"time":1782087751674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"First"}}} +{"type":"assistant/chunk","seq":114,"time":1782087751762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":115,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":116,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":117,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":118,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":119,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":120,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":121,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":122,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":123,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":124,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":125,"time":1782087751848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":126,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":127,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":128,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":129,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":130,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":131,"time":1782087751877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":132,"time":1782087751966,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":133,"time":1782087751966,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":134,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":135,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":136,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":137,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":139,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"Second"}}} +{"type":"assistant/chunk","seq":141,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" subt"}}} +{"type":"assistant/chunk","seq":142,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":143,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":144,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":145,"time":1782087752053,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":146,"time":1782087752053,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":147,"time":1782087752082,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":148,"time":1782087752083,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":149,"time":1782087752083,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":150,"time":1782087752083,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":151,"time":1782087752112,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":152,"time":1782087752113,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":153,"time":1782087752113,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1782087752113,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":155,"time":1782087752140,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":156,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":157,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":158,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":159,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":160,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":161,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":162,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":163,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":164,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":165,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":166,"time":1782087752199,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":167,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I need to call the second subagent."}}}} +{"type":"assistant/chunk","seq":168,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","arguments":"{\"description\": \"Second subtask: BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":169,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":94,"cacheReadTokens":1280,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":170,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":171,"time":1782087752261,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I need to call the second subagent."},{"type":"tool-call","id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","arguments":"{\"description\": \"Second subtask: BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"usage":{"inputTokens":63,"outputTokens":94,"cacheReadTokens":1280,"reasoningTokens":19}}} +{"type":"tool/call","seq":172,"time":1782087752261,"data":{"turn":1,"step":2,"callId":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","arguments":"{\"description\": \"Second subtask: BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} +{"type":"tool/result","seq":173,"time":1782087753008,"data":{"turn":1,"step":2,"callId":"call_00_G13lPXMKGT1h6ms60n411588","content":[{"type":"text","text":"BETA"}],"isError":false}} +{"type":"step/end","seq":174,"time":1782087753008,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":175,"time":1782087753008,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":176,"time":1782087753643,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":177,"time":1782087753643,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":178,"time":1782087753776,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":179,"time":1782087753806,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":180,"time":1782087753807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":181,"time":1782087753807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":182,"time":1782087753807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":183,"time":1782087753850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":184,"time":1782087753851,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":185,"time":1782087753851,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":186,"time":1782087753870,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":187,"time":1782087753870,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":188,"time":1782087753871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":189,"time":1782087753871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":190,"time":1782087753871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":191,"time":1782087753901,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":192,"time":1782087753901,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":193,"time":1782087753902,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":194,"time":1782087753902,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":195,"time":1782087753902,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":196,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":197,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":198,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":199,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":200,"time":1782087753936,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":201,"time":1782087753936,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":202,"time":1782087753966,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":203,"time":1782087753966,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":204,"time":1782087753966,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":205,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned. Now I need to reply with exactly \"PARENT_DONE\" and nothing else."}}}} +{"type":"assistant/chunk","seq":206,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":207,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":43,"outputTokens":28,"cacheReadTokens":1408,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":208,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":209,"time":1782087753967,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned. Now I need to reply with exactly \"PARENT_DONE\" and nothing else."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":43,"outputTokens":28,"cacheReadTokens":1408,"reasoningTokens":23}}} +{"type":"step/end","seq":210,"time":1782087753967,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":211,"time":1782087753967,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl new file mode 100644 index 0000000000..1cfce85dc3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -0,0 +1,115 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" twice"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_T91HrbiohZjyqZ7biX4s4408","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"First subtask: ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_T91HrbiohZjyqZ7biX4s4408","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_G13lPXMKGT1h6ms60n411588","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Second subtask: BETA","prompt":"Reply with exactly the word BETA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_G13lPXMKGT1h6ms60n411588","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/input.json b/examples/acp-agent/tests/snapshots/subagent-spawn/input.json new file mode 100644 index 0000000000..3cd6f5350d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl new file mode 100644 index 0000000000..d32cf4b836 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"4d76c4bd-1fca-418f-b2fe-b938d7398666","createdAt":1782087699201,"cwd":"/tmp/acp-snap-cwd-s06Syv","parentSession":"9b045576-92f1-48ca-b854-9ba160449992"} +{"type":"turn/start","seq":0,"time":1782087699202,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087699202,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782087699202,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087699839,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087699839,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087699947,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782087700016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782087700016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782087700017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782087700017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":16,"time":1782087700034,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} +{"type":"assistant/chunk","seq":25,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":26,"time":1782087700107,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":27,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":28,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":29,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":964,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":30,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1782087700108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"CHILD_OK\" and nothing else."},{"type":"text","text":"CHILD_OK"}],"usage":{"inputTokens":964,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}} +{"type":"step/end","seq":32,"time":1782087700108,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1782087700108,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl new file mode 100644 index 0000000000..af1b1856b6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -0,0 +1,142 @@ +{"type":"session","version":0,"id":"9b045576-92f1-48ca-b854-9ba160449992","createdAt":1782087697853,"cwd":"/tmp/acp-snap-cwd-s06Syv"} +{"type":"turn/start","seq":0,"time":1782087697856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087697856,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782087697857,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087698282,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087698283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087698376,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":10,"time":1782087698407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1782087698435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":12,"time":1782087698435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":13,"time":1782087698436,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1782087698436,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":15,"time":1782087698464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1782087698464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1782087698464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":18,"time":1782087698492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":19,"time":1782087698492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":21,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":22,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":23,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1782087698522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":25,"time":1782087698522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} +{"type":"assistant/chunk","seq":26,"time":1782087698549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":30,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":31,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":32,"time":1782087698578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":33,"time":1782087698579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":34,"time":1782087698607,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":35,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":36,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":37,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":38,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":39,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":40,"time":1782087698636,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":41,"time":1782087698665,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1782087698666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1782087698666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":44,"time":1782087698694,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":45,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":46,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":47,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":48,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":50,"time":1782087698726,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":51,"time":1782087698727,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":52,"time":1782087698727,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} +{"type":"assistant/chunk","seq":53,"time":1782087698756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":54,"time":1782087698756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":55,"time":1782087698785,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" usage"}}} +{"type":"assistant/chunk","seq":56,"time":1782087698818,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1782087698875,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":58,"time":1782087698875,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":59,"time":1782087698921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":60,"time":1782087698921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1782087698922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":62,"time":1782087698933,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":63,"time":1782087698933,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":64,"time":1782087698934,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1782087698934,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":66,"time":1782087698959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":67,"time":1782087698960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":68,"time":1782087698988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":69,"time":1782087698988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":70,"time":1782087698988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1782087699021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":72,"time":1782087699021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1782087699021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":74,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":75,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":77,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":79,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":81,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":82,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":83,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":84,"time":1782087699107,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":85,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":86,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":87,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":88,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":89,"time":1782087699137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":90,"time":1782087699137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1782087699137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":92,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool once with the specific prompt \"Reply with exactly the word CHILD_OK and nothing else.\" Then after the subagent returns, I should reply with \"PARENT_DONE\" and stop. No bash tool usage."}}}} +{"type":"assistant/chunk","seq":93,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":94,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1159,"outputTokens":128,"cacheReadTokens":0,"reasoningTokens":53}}}} +{"type":"assistant/chunk","seq":95,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":96,"time":1782087699200,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool once with the specific prompt \"Reply with exactly the word CHILD_OK and nothing else.\" Then after the subagent returns, I should reply with \"PARENT_DONE\" and stop. No bash tool usage."},{"type":"tool-call","id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":1159,"outputTokens":128,"cacheReadTokens":0,"reasoningTokens":53}}} +{"type":"tool/call","seq":97,"time":1782087699200,"data":{"turn":1,"step":1,"callId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":98,"time":1782087700114,"data":{"turn":1,"step":1,"callId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","content":[{"type":"text","text":"CHILD_OK"}],"isError":false}} +{"type":"step/end","seq":99,"time":1782087700114,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":100,"time":1782087700114,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":101,"time":1782087700497,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":102,"time":1782087700497,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":103,"time":1782087700630,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":104,"time":1782087700660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":105,"time":1782087700660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":106,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":107,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":108,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":109,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":110,"time":1782087700687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1782087700716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":112,"time":1782087700717,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} +{"type":"assistant/chunk","seq":113,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":114,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":115,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":116,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":117,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":118,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":119,"time":1782087700774,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":120,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":122,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":123,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":124,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":125,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":127,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":128,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":129,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":130,"time":1782087700805,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":131,"time":1782087700833,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":132,"time":1782087700833,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":133,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":134,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":135,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":136,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":22,"outputTokens":32,"cacheReadTokens":1280,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":137,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":138,"time":1782087700834,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":22,"outputTokens":32,"cacheReadTokens":1280,"reasoningTokens":27}}} +{"type":"step/end","seq":139,"time":1782087700835,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":140,"time":1782087700835,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl new file mode 100644 index 0000000000..3c78183324 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -0,0 +1,89 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" No"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" usage"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" expected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 8d19c464fd..54a9387518 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -570,6 +570,7 @@ async function runStep( messages: session.deriveMessages(), ...system ? { system } : {}, ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, + sessionId: session.id, signal, } request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request)) diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 63fc0f5b0c..7b1b9bdc47 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -19,6 +19,7 @@ * ``` */ +import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId } from './brand.ts' /** Cache hint attached to a content block (provider-interpreted). */ @@ -192,4 +193,18 @@ export interface GenerateOptions { */ stop?: string[] signal?: AbortSignal + /** + * The id of the session this request belongs to — stamped by the agent loop + * from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener + * route a call by WHICH session issued it (the replay adapter keys its per-call + * cursor by session, so a parent and its in-process subagent — each with its + * own session on one context — replay from their own recorded scripts). + * + * Typed as `Branded<'SessionId'>` rather than importing `SessionId` from + * `dsh-session`: that package imports `Message` from here, so importing its + * `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a + * real session id assigns with no cast. (A future ids package could own the + * brand and dissolve this note.) + */ + sessionId?: Branded<'SessionId'> } diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 91230cc113..06803eaf0e 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -10,26 +10,35 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script. +## Nested agents: per-session keying + +A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script. + +Replay keys every call by its calling session id (`GenerateOptions.sessionId`, stamped by the agent loop). Live session ids are freshly random each run and never equal the recorded ones, so a live session binds to a recorded script by **first-call order**: scripts are ordered by header `createdAt` (parent first — it streams before it can delegate), and the first live session to make any call claims the first script, the next new session the next, and so on. Each session then advances its own cursor. A call with no `sessionId` is one anonymous session bound to the primary script, so single-session scenarios behave exactly as before. More distinct live sessions than recorded scripts fails loud. + ## Config | Key | Type | Default | Notes | |---|---|---|---| -| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the per-scenario `session.jsonl` fixture. Required (config or env). | -| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the derived script. | +| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | +| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. | +| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | ```yaml - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' - # file/overrideFile default to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE, - # set by the snapshot harness per scenario. + # file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE / + # $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot + # harness per scenario. ``` ## Exports - `installLlmReplay(ctx, config)` — install the `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars. -- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for a scenario (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` — the pure helpers that turn a recorded session log into a script. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. -- Types `ReplayEntry` / `ReplayConfig` / `Config`. +- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. +- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing). +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index fe24213768..ce761e6f13 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -14,6 +14,15 @@ * therefore "run the real agent once and harvest the `.jsonl`", done by the * snapshot harness — this plugin does not record. * + * A NESTED-agent scenario records more than one log: the parent plus one per + * in-process subagent (each subagent runs as its own {@link Session} on the same + * context). Replay loads them all ({@link loadSessionScripts}), derives a script + * per recorded session, and keys each live call by its calling session id + * (`GenerateOptions.sessionId`, stamped by the loop). Live session ids are fresh + * random values, so a live session binds to a recorded script by FIRST-CALL + * order (parent first — it streams before it delegates); see + * {@link installLlmReplay}. + * * Two failure modes are NOT reconstructable from `assistant/chunk` alone — a * pure throw before any chunk (e.g. an HTTP 401: the log holds only a * `turn/end {error}`, no chunks) and a cancel/hang (timing, not chunk content). @@ -36,6 +45,7 @@ */ import { existsSync, readFileSync } from 'node:fs' +import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -65,14 +75,49 @@ export type ReplayEntry = /** Resolved plugin configuration. */ export interface ReplayConfig { - /** Path to the per-scenario `session.jsonl` fixture (the recorded log). */ + /** + * Path to the PRIMARY (parent) `session.jsonl` fixture. For a single-session + * scenario this is the only log; for a nested-agent scenario it is the parent, + * and the child logs ride in {@link childFiles}. + */ file: string /** - * Optional path to a `ReplayEntry[]` sidecar that REPLACES the derived - * script. Used by the two scenarios not expressible as `assistant/chunk` - * (pure throw-before-chunk, cancel/hang). Absent for normal scenarios. + * Optional `ReplayEntry[]` sidecar that REPLACES the derived script for the + * PRIMARY session. Used by the two single-session scenarios not expressible as + * `assistant/chunk` (pure throw-before-chunk, cancel/hang). Absent for normal + * and nested scenarios. */ overrideFile?: string + /** + * Additional recorded child-session logs (a nested-agent scenario's subagent + * sessions). Each is derived independently; the full set is ordered by + * `createdAt` so the parent (earliest) binds to the first live session. Empty + * for a single-session scenario. + */ + childFiles?: string[] +} + +/** + * One recorded session's replay script: the per-call entries plus the header + * facts needed to ORDER and key it. Live session ids are freshly random at + * replay time and never equal the recorded `id`, so the recorded id is only a + * diagnostic; `createdAt` is the load-bearing field — scripts are ordered by it + * (a parent is created before its children) and each newly-seen live session is + * bound to the next script in that order (= first-call order in the synchronous + * nested cut, where the parent streams before it delegates). + */ +export interface SessionScript { + /** The recorded session id (diagnostics only — the live id differs). */ + recordedId: string + /** Session creation time; the deterministic ordering key (parent < child). */ + createdAt: number + /** The per-`stream()`-call replay entries, in recorded call order. */ + entries: ReplayEntry[] + /** + * Whether this is the PRIMARY (parent) session. Breaks a `createdAt` tie in + * favor of the parent, which always issues the first model call. + */ + primary: boolean } /** @@ -93,6 +138,23 @@ export function parseSessionLog(text: string): SessionEvent[] { return events } +/** + * Read the identifying facts off a session log's header line (line 0): the + * recorded session `id` (diagnostics) and `createdAt` (the deterministic + * ordering key that binds a recorded script to a live session — see + * {@link SessionScript}). A header missing either field falls back to a stable + * default (`''` / `0`) rather than throwing: a no-model fixture is header-only + * and still orders fine as the single (primary) script. + */ +export function parseSessionHeader(text: string): { id: string; createdAt: number } { + const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}' + const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown } + return { + id: typeof parsed.id === 'string' ? parsed.id : '', + createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0, + } +} + /** * Reconstruct the per-`stream()` replay script from a recorded session log. * @@ -144,11 +206,11 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { } /** - * Build the replay script for a scenario: the sidecar override if present, - * otherwise the script derived from the recorded session JSONL. Fail-loud if - * the JSONL fixture is missing (the scenario was never recorded) — never - * silently returns an empty script, so a coverage hole can't masquerade as a - * passing replay. + * Build the replay script for the PRIMARY session: the sidecar override if + * present, otherwise the script derived from the recorded session JSONL. + * Fail-loud if the JSONL fixture is missing (the scenario was never recorded) — + * never silently returns an empty script, so a coverage hole can't masquerade + * as a passing replay. */ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { if (config.overrideFile !== undefined && existsSync(config.overrideFile)) { @@ -164,6 +226,54 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { return deriveReplayScript(parseSessionLog(readFileSync(config.file, 'utf8'))) } +/** + * Load every recorded session's script for a scenario, ordered by `createdAt` + * (earliest first), ready to bind to live sessions in first-call order. + * + * The PRIMARY session (`config.file`, with its optional `overrideFile`) is the + * parent; each `config.childFiles` entry is a recorded subagent session. A + * single-session scenario has no `childFiles`, so this returns one script and + * behaves exactly like the old single-cursor replay. The primary always sorts + * first when ties occur (a sub-millisecond parent/child `createdAt` collision): + * the parent issues the FIRST model call (it must stream before it can delegate + * in the synchronous nested cut), so binding it to the first live session is + * correct regardless of a timestamp tie. + */ +export function loadSessionScripts(config: ReplayConfig): SessionScript[] { + const primaryEntries = loadReplayScript(config) + // The override path replaces the derived script but carries no header; read + // the header off the JSONL when it exists, else use a stable default so an + // override-only fixture (header-less) still orders first as the primary. + const primaryHeader = existsSync(config.file) + ? parseSessionHeader(readFileSync(config.file, 'utf8')) + : { id: '', createdAt: 0 } + const primary: SessionScript = { + recordedId: primaryHeader.id, createdAt: primaryHeader.createdAt, entries: primaryEntries, primary: true, + } + const children: SessionScript[] = [] + for (const childFile of config.childFiles ?? []) { + if (!existsSync(childFile)) { + throw new Error(`llm-replay: child fixture not found: ${childFile} — re-record the scenario`) + } + const text = readFileSync(childFile, 'utf8') + const header = parseSessionHeader(text) + children.push({ + recordedId: header.id, + createdAt: header.createdAt, + entries: deriveReplayScript(parseSessionLog(text)), + primary: false, + }) + } + // The primary (parent) always binds first — it issues the first model call, + // because it must run a turn before it can delegate. Children follow in + // createdAt order (the order they were spawned in the synchronous nested cut), + // ties broken by recorded id for determinism. Keeping the primary at the head + // rather than sorting it among the children means a sub-millisecond + // parent/child createdAt collision can never reorder it behind a child. + children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId)) + return [primary, ...children] +} + /** Yield a recorded stream back, honoring abort like a real adapter. */ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable { switch (entry.kind) { @@ -206,32 +316,70 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) * disposer (so a fiber dispose removes it — HMR safety). Exported separately * from {@link apply} so unit tests can drive it without the Loader or env vars. * - * Replay is POSITIONAL: the Nth `stream()` call serves the Nth script entry. - * This is deterministic only with at most one model stream in flight at a time; - * the snapshot harness runs one ACP session per scenario to guarantee that. The - * cursor is advanced synchronously at listener-invocation time (not lazily - * inside the generator) so call ORDER, not iteration order, fixes the mapping. + * Replay is PER-SESSION POSITIONAL: each recorded session has its own script + * (parent + any subagent children, loaded by {@link loadSessionScripts} ordered + * by `createdAt`), and the Nth `stream()` call FROM A GIVEN SESSION serves that + * session's Nth entry. The calling session is read off `options.sessionId` (the + * agent loop stamps it from `agent.session.id`). * - * TODO(subagent-snapshots): this single global cursor cannot route calls to the - * right agent when a parent and an in-process subagent both stream on one ctx. - * Snapshot coverage of nested agents needs either per-session-keyed replay (a - * `Map` fed by the calling agent on the `agent/request` - * waterfall, which carries the agent) or a call-ordered merge of the parent and - * child session logs (sound because subagent execution is strictly nested — - * the parent blocks on the child). Tracked as a stacked follow-up to the - * in-process subagent backends; see the subagent RFC's "Snapshot coverage of - * nested agents" deferral. + * Live session ids are freshly random and never equal the recorded ones, so a + * live session binds to a recorded script by FIRST-CALL ORDER: the first live + * session to make any call takes the first ordered script (the parent — earliest + * `createdAt`, and the first to stream because it must run before it delegates), + * the next new live session takes the next script, and so on. This keys by WHO + * calls rather than global call order, so it stays correct even if subagents + * ever run concurrently/backgrounded (a global cursor would interleave them). + * + * A call with no `sessionId` (a direct unit-test `ctx.llm.stream` that omits it) + * is treated as one anonymous session — it binds to the first script, so the + * single-session path behaves exactly as the old global cursor did. + * + * Each per-session cursor advances synchronously at listener-invocation time + * (not lazily inside the generator) so call ORDER within a session, not + * iteration order, fixes the mapping. */ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void { - const entries = loadReplayScript(config) - let cursor = 0 + const scripts = loadSessionScripts(config) + // Live-session → its bound script + cursor. A new live session id claims the + // next not-yet-bound script (scripts are in bind order); `nextScript` is the + // index of the next unclaimed one. + const bound = new Map() + let nextScript = 0 + const ANON = '\0anon\0' // the key for a call that carries no sessionId return ctx.on('llm/stream', (options: GenerateOptions, _next) => { - const index = cursor++ - const entry: ReplayEntry | undefined = entries[index] + const key = options.sessionId ?? ANON + let state = bound.get(key) + let unrecorded = false + if (state === undefined) { + const script = scripts[nextScript] + if (script === undefined) { + // More distinct live sessions made calls than the scenario recorded — + // an unrecorded subagent appeared. Defer the throw into the returned + // generator (the listener must return an AsyncIterable, not throw). + unrecorded = true + state = { entries: [], cursor: 0 } + } else { + nextScript++ + state = { entries: script.entries, cursor: 0 } + bound.set(key, state) + } + } + const boundState = state + const seenSessions = nextScript + const totalScripts = scripts.length + const index = boundState.cursor++ + const entry: ReplayEntry | undefined = boundState.entries[index] return (async function* () { + if (unrecorded) { + throw new Error( + `llm-replay: a model call arrived from an unrecorded session (#${seenSessions + 1}); ` + + `the scenario recorded only ${totalScripts} session(s) — re-record it`, + ) + } if (entry === undefined) { throw new Error( - `llm-replay: script exhausted — requested model call #${index + 1} but the fixture has only ${entries.length}; re-record the scenario`, + `llm-replay: script exhausted — session requested model call #${index + 1} ` + + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } yield* replayEntry(entry, options.signal) @@ -247,6 +395,12 @@ export interface Config { file?: string /** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */ overrideFile?: string + /** + * Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a + * path-separator-delimited list). Each is a recorded subagent session log for + * a nested-agent scenario; absent/empty for a single-session scenario. + */ + childFiles?: string[] } export function apply(ctx: Context, config: Config = {}): void { @@ -255,5 +409,12 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)') } const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE - installLlmReplay(ctx, overrideFile === undefined || overrideFile.length === 0 ? { file } : { file, overrideFile }) + const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES + const childFiles = config.childFiles + ?? (childEnv !== undefined && childEnv.length > 0 ? childEnv.split(pathDelimiter) : []) + installLlmReplay(ctx, { + file, + ...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {}, + ...childFiles.length > 0 ? { childFiles } : {}, + }) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 925881273a..a0c6268eff 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -7,12 +7,15 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, + type SessionScript, apply, deriveReplayScript, inject, installLlmReplay, loadReplayScript, + loadSessionScripts, name, + parseSessionHeader, parseSessionLog, } from '../src/index.ts' @@ -32,9 +35,14 @@ const TEXT_CHUNKS: StreamChunk[] = [ ] /** Build a minimal session-JSONL string: a header line + the given events. */ -function sessionJsonl(events: SessionEvent[]): string { - const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) - return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' +function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number }): string { + const headerLine = JSON.stringify({ + type: 'session', + version: 0, + id: header?.id ?? 's1', + createdAt: header?.createdAt ?? 0, + }) + return [headerLine, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' } /** A SessionEvent of type assistant/chunk for (turn, step). */ @@ -361,13 +369,188 @@ describe('installLlmReplay (through the real waterfall)', () => { }) }) +describe('parseSessionHeader', () => { + it('reads id and createdAt off the header line', () => { + expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 }))) + .toEqual({ id: 'abc', createdAt: 42 }) + }) + + it('falls back to id="" / createdAt=0 when the header lacks them', () => { + expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0 }) + }) + + it('falls back on an empty buffer (no header line)', () => { + expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0 }) + }) +}) + +describe('loadSessionScripts', () => { + /** Write a session log file and return its path. */ + function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { + let seq = 1 + const events: SessionEvent[] = [] + calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) + const path = join(dir, filename) + writeFileSync(path, sessionJsonl(events, header), 'utf8') + return path + } + + it('returns one primary script for a single-session scenario', () => { + const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts: SessionScript[] = loadSessionScripts({ file: f }) + expect(scripts).toHaveLength(1) + expect(scripts[0]).toMatchObject({ recordedId: 'p', createdAt: 100, primary: true }) + expect(scripts[0]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) + }) + + it('orders parent + children by createdAt with the primary first on a tie', () => { + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + // One child created LATER, one child sharing the parent's createdAt (tie). + const later = writeSession('session.1.jsonl', { id: 'late', createdAt: 200 }, [TEXT_CHUNKS]) + const tie = writeSession('session.2.jsonl', { id: 'tie', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file: f, childFiles: [later, tie] }) + // parent (100, primary) → tie (100, non-primary) → late (200). + expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'tie', 'late']) + expect(scripts[0]?.primary).toBe(true) + }) + + it('throws when a declared child fixture is missing', () => { + const f = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS]) + expect(() => loadSessionScripts({ file: f, childFiles: [join(dir, 'absent.jsonl')] })) + .toThrow(/child fixture not found/) + }) + + it('uses the override for the primary and still derives children', () => { + writeFileSync(file, sessionJsonl([], { id: 'p', createdAt: 1 }), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + const override: ReplayEntry[] = [{ kind: 'hang' }] + writeFileSync(overrideFile, JSON.stringify(override), 'utf8') + const child = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file, overrideFile, childFiles: [child] }) + expect(scripts[0]?.entries).toEqual(override) + expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) + }) + + it('defaults the primary header to id="" / createdAt=0 when only an override (no JSONL) exists', () => { + // An override-only fixture: config.file does NOT exist, the override drives + // the primary script, so the header default branch applies. + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang' }]), 'utf8') + const scripts = loadSessionScripts({ file: join(dir, 'absent.jsonl'), overrideFile }) + expect(scripts).toHaveLength(1) + expect(scripts[0]).toMatchObject({ recordedId: '', createdAt: 0, primary: true }) + }) + + it('orders two same-createdAt children deterministically after the primary', () => { + // Two children sharing a createdAt (both non-primary): exercises the sort + // tie-break\'s "both same primary-ness" arm and a non-primary-vs-primary arm. + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + const c1 = writeSession('session.1.jsonl', { id: 'c1', createdAt: 100 }, [TEXT_CHUNKS]) + const c2 = writeSession('session.2.jsonl', { id: 'c2', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file: f, childFiles: [c1, c2] }) + // Primary first (its createdAt ties the children but primary wins); the two + // children keep a stable relative order. + expect(scripts[0]?.recordedId).toBe('parent') + expect(scripts.every(s => s.createdAt === 100)).toBe(true) + expect(scripts.map(s => s.primary)).toEqual([true, false, false]) + }) + + it('keeps the primary first even when a child sorts BEFORE it in input order', () => { + // The primary is appended first internally but the child has an EARLIER + // createdAt — the primary must still win on the tie-break against a + // later-but-equal child, and lose only to a genuinely earlier child via + // createdAt (here the child is earlier, so order is child-then-primary only + // if createdAt strictly less; equal createdAt keeps primary first). + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + const earlier = writeSession('session.1.jsonl', { id: 'early', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file: f, childFiles: [earlier] }) + // Equal createdAt → primary first. + expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'early']) + }) +}) + +describe('installLlmReplay (per-session keying)', () => { + const second: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'child' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + + /** Write a session log file and return its path. */ + function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { + let seq = 1 + const events: SessionEvent[] = [] + calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) + const path = join(dir, filename) + writeFileSync(path, sessionJsonl(events, header), 'utf8') + return path + } + + const live = (id: string): GenerateOptions => + ({ model: 'm', messages: [], sessionId: id as NonNullable }) + + it('routes each live session to its own script by FIRST-CALL order', async () => { + const parentFile = writeSession('session.jsonl', { id: 'rec-parent', createdAt: 100 }, [TEXT_CHUNKS]) + const childFile = writeSession('session.1.jsonl', { id: 'rec-child', createdAt: 200 }, [second]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] }) + // The first live session to call binds to the parent script; a different + // live session id binds to the child script — regardless of recorded ids. + expect(await drain(ctx.llm.stream(live('live-A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('live-B')))).toEqual(second) + // The first session's SECOND call would exhaust its 1-entry script. + await expect(drain(ctx.llm.stream(live('live-A')))).rejects.toThrow(/exhausted/) + }) + + it('keeps each session\'s cursor independent (interleaved calls)', async () => { + const a2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'a2' }, { type: 'finish', reason: { kind: 'stop' } }] + const b2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'b2' }, { type: 'finish', reason: { kind: 'stop' } }] + const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS, a2]) + const childFile = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [second, b2]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] }) + // Interleave: A#1, B#1, A#2, B#2 — each cursor advances per-session. + expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(second) + expect(await drain(ctx.llm.stream(live('A')))).toEqual(a2) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(b2) + }) + + it('treats a call with no sessionId as the single anonymous (primary) session', async () => { + const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile }) + // No sessionId at all — the legacy single-session path. + expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) + }) + + it('fails loud when more distinct live sessions call than were recorded', async () => { + const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile }) // only ONE recorded session + expect(await drain(ctx.llm.stream(live('first')))).toEqual(TEXT_CHUNKS) + // A SECOND distinct live session has no script to bind to. + await expect(drain(ctx.llm.stream(live('second')))).rejects.toThrow(/unrecorded session/) + }) +}) + describe('apply (the plugin entry)', () => { - const ORIG = { file: process.env.DSH_SNAPSHOT_FILE, override: process.env.DSH_SNAPSHOT_OVERRIDE } + const ORIG = { + file: process.env.DSH_SNAPSHOT_FILE, + override: process.env.DSH_SNAPSHOT_OVERRIDE, + children: process.env.DSH_SNAPSHOT_CHILD_FILES, + } afterEach(() => { if (ORIG.file === undefined) delete process.env.DSH_SNAPSHOT_FILE else process.env.DSH_SNAPSHOT_FILE = ORIG.file if (ORIG.override === undefined) delete process.env.DSH_SNAPSHOT_OVERRIDE else process.env.DSH_SNAPSHOT_OVERRIDE = ORIG.override + if (ORIG.children === undefined) delete process.env.DSH_SNAPSHOT_CHILD_FILES + else process.env.DSH_SNAPSHOT_CHILD_FILES = ORIG.children }) it('exposes the namespace plugin shape (name/inject, no default export)', () => { @@ -418,4 +601,52 @@ describe('apply (the plugin entry)', () => { await ctx.plugin(LlmService) expect(() => { apply(ctx, { file: '' }) }).toThrow(/a fixture path is required/) }) + + it('loads child fixtures from config.childFiles (per-session routing)', async () => { + const childSecond: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'kid' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + const childFile = join(dir, 'session.1.jsonl') + writeFileSync(childFile, sessionJsonl(childSecond.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + apply(ctx, { file, childFiles: [childFile] }) + const live = (id: string): GenerateOptions => + ({ model: 'm', messages: [], sessionId: id as NonNullable }) + expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(childSecond) + }) + + it('falls back to $DSH_SNAPSHOT_CHILD_FILES (path-delimited) when config omits childFiles', async () => { + const childChunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'env-kid' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + const childFile = join(dir, 'session.1.jsonl') + writeFileSync(childFile, sessionJsonl(childChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') + process.env.DSH_SNAPSHOT_FILE = file + process.env.DSH_SNAPSHOT_CHILD_FILES = childFile // single entry, no delimiter needed + const ctx = new Context() + await ctx.plugin(LlmService) + apply(ctx) + const live = (id: string): GenerateOptions => + ({ model: 'm', messages: [], sessionId: id as NonNullable }) + expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(childChunks) + }) + + it('ignores an empty $DSH_SNAPSHOT_CHILD_FILES (single-session)', async () => { + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + process.env.DSH_SNAPSHOT_FILE = file + process.env.DSH_SNAPSHOT_CHILD_FILES = '' + const ctx = new Context() + await ctx.plugin(LlmService) + apply(ctx) + expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) + }) }) From 413db680088eb7cebf95122e786e894519f6fc8d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:01:09 +0800 Subject: [PATCH 25/40] Clarify the child-ordering invariant (Codex review follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The createdAt+recordedId child sort comment over-claimed "tie-safe". Codex flagged that a same-millisecond sibling tie would be broken by random session id, which does not recover first-call order. In the current synchronous cut that tie is unreachable — the subagent tool awaits one child's result and disposes it before the parent starts the next, so siblings' createdAt values are strictly ordered and match first-call order. Restate the comment to that real invariant (at both the replay sort and the harvest sort), note that the id tiebreak only makes a degenerate collision deterministic, and flag the concurrent-subagent cut that would need a real first-call ordinal with XXX(concurrent-subagents). The RFC records the same limitation. Comment/doc only — no behavior change. --- .../2026-06-22-subagent-snapshot-replay.md | 2 ++ examples/acp-agent/tests/snapshot-harness.ts | 9 +++++++-- packages/support/llm-replay/src/index.ts | 16 ++++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 92a324104e..422c8d0301 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -29,6 +29,8 @@ Live session ids are freshly random every run and never equal the recorded ones, This keys by WHO calls, not by global call order — so it stays correct even if subagents ever run concurrently or in the background (a global cursor would interleave them). A call carrying no `sessionId` (a direct unit-test `stream()`) is treated as one anonymous session bound to the primary script, so the single-session path is byte-for-byte the old behavior. More distinct live sessions than recorded scripts is a fail-loud error (an unrecorded subagent appeared), never a silent mis-route. +The ordering key is the session header `createdAt`. In the current synchronous cut this is sound because sibling children are created **strictly sequentially** — the subagent tool awaits one child's result and disposes it before the parent's next tool call starts the next child — so their `createdAt` values are strictly ordered and match first-call order exactly. A same-millisecond sibling tie is therefore unreachable; the `recordedId` tiebreak only keeps such a degenerate collision deterministic, it does not recover first-call order. A future cut that runs siblings concurrently/backgrounded WOULD be able to create two children in the same millisecond, and must then thread a real first-call ordinal (the order live sessions first stream) rather than leaning on `createdAt` — flagged with `XXX(concurrent-subagents)` at the sort site. + The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for today's synchronous cut but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not. ### 3. The harness harvests every log, primary-first diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index 80847f08dd..8285b870bf 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -369,8 +369,13 @@ async function harvestSessionLogs(root: string): Promise { } } // Primary (no parentSession) first, then children by ascending createdAt. A - // scenario has exactly one top-level session; ties among children fall back to - // recorded id for a stable order. + // scenario has exactly one top-level session. In the synchronous cut sibling + // children are created strictly sequentially, so their createdAt values are + // strictly ordered; the recordedId tiebreak only keeps a degenerate + // same-millisecond collision (unreachable here) deterministic. This harvest + // order must match the replay load order in dsh-llm-replay's loadSessionScripts + // so session..jsonl maps to the same child on record and replay — replay + // re-sorts childFiles by the same key, so the two stay consistent. logs.sort((a, b) => { const ap = a.parentSession === undefined ? 0 : 1 const bp = b.parentSession === undefined ? 0 : 1 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index ce761e6f13..b804c8ebd9 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -266,10 +266,18 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { } // The primary (parent) always binds first — it issues the first model call, // because it must run a turn before it can delegate. Children follow in - // createdAt order (the order they were spawned in the synchronous nested cut), - // ties broken by recorded id for determinism. Keeping the primary at the head - // rather than sorting it among the children means a sub-millisecond - // parent/child createdAt collision can never reorder it behind a child. + // createdAt order. In the current synchronous cut sibling children are created + // STRICTLY SEQUENTIALLY — the subagent tool awaits one child's result and + // disposes it before the parent's next tool call can start the next — so their + // createdAt values are strictly ordered and match first-call order exactly. + // The recordedId tiebreak only makes a degenerate same-millisecond collision + // (unreachable in this cut) deterministic; it does NOT recover first-call + // order, so it is arbitrary if such a tie ever occurs. + // XXX(concurrent-subagents): a future cut that runs siblings concurrently or + // backgrounded could create two children in the same millisecond, where this + // createdAt+id order may diverge from first-call order. That cut must thread a + // real first-call ordinal (the order live sessions first stream) instead of + // leaning on createdAt — see the per-session-replay RFC. children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId)) return [primary, ...children] } From 67317938595e2b4925130f5068730d4373ce366f Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 22 Jun 2026 09:02:58 +0800 Subject: [PATCH 26/40] revert: use rewriteRelativeImportExtensions for NextNode .d.ts resolve --- examples/acp-agent/tests/acp.snapshot.ts | 4 ++-- examples/acp-agent/tests/snapshot-normalize.spec.ts | 2 +- examples/coding-agent/tests/coding-task.e2e.ts | 2 +- examples/coding-agent/tests/full-loop.e2e.ts | 2 +- examples/coding-agent/tests/resume.e2e.ts | 2 +- packages/bash/tool-bash/tests/integration.spec.ts | 2 +- packages/core/agent-core/tests/agent-core.spec.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 2 +- packages/core/agent-loop/tests/config-session-id.spec.ts | 2 +- packages/core/agent-loop/tests/coverage-edges.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 2 +- packages/core/agent-loop/tests/review-fixes.spec.ts | 2 +- packages/core/agent/tests/gen-cordis-catalog.spec.ts | 2 +- packages/core/session/tests/repair.spec.ts | 4 ++-- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- packages/llm/llm-deepseek/tests/adapter.spec.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- .../session-persistence-jsonl/tests/jsonl.spec.ts | 6 +++--- .../session-persistence-sqlite/tests/sqlite.spec.ts | 6 +++--- .../session-persistence/tests/contract.ts | 2 +- .../session-persistence/tests/coordinator-contract.ts | 4 ++-- .../session-persistence/tests/persistence.spec.ts | 6 +++--- packages/support/llm-replay/tests/llm-replay.spec.ts | 2 +- packages/support/ui-stdio/tests/ui-stdio.spec.ts | 2 +- packages/ui/acp-agent/tests/acp-agent.spec.ts | 2 +- packages/ui/acp/tests/bridge.spec.ts | 2 +- packages/ui/acp/tests/codec.spec.ts | 2 +- packages/ui/acp/tests/dispose.spec.ts | 2 +- packages/ui/acp/tests/edges.spec.ts | 2 +- packages/ui/acp/tests/harness.ts | 4 ++-- packages/ui/acp/tests/load.spec.ts | 2 +- packages/ui/acp/tests/multi-session.spec.ts | 2 +- packages/ui/acp/tests/properties.spec.ts | 2 +- packages/ui/acp/tests/stream-update.spec.ts | 2 +- packages/ui/acp/tests/turns.spec.ts | 2 +- packages/ui/stdio-agent/tests/stdio-agent.spec.ts | 2 +- 39 files changed, 49 insertions(+), 49 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index d5a6e1551f..be6aabada0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -3,8 +3,8 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { type InputScript, runScenario } from './snapshot-harness' -import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize' +import { type InputScript, runScenario } from './snapshot-harness.ts' +import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize.ts' /** * ACP snapshot tests (REPLAY by default, keyless). Each scenario under diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index a83d7682e0..b220344bb9 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from '../tests/snapshot-normalize' +import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from '../tests/snapshot-normalize.ts' /** * Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index 2f3dc2ae3b..68bca5cdfa 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { AgentId } from '@deepseek-ai/dsh-agent' -import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' /** * The swebench-style smoke test: a real model fixes a real bug in a temp diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 7a511d6d99..2b70d6f339 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { AgentId } from '@deepseek-ai/dsh-agent' -import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' /** * The first place a REAL model meets the REAL bash tool: the cheap canary diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index c829f34ced..450938fc6d 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' /** * Proves durable conversation continuity end-to-end: run 1 tells the REAL model diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 154d3dc67a..a67809ffee 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -10,7 +10,7 @@ import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { BashTaskId } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** * Full-loop integration: a scripted mock model drives the REAL bash tool diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index fe3d89eca6..67f5d88532 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import * as agentCore from '../src/index' +import * as agentCore from '../src/index.ts' import { AgentId } from '@deepseek-ai/dsh-agent' /** diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 54ebc9fdc5..ed163bf4c2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter' +import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 71b1b80ea4..9cdaa1973b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -18,7 +18,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter' +import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index c3045c7f7c..8cf5bd81f8 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -10,7 +10,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter' +import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 2fec2b2c66..3eefbf6986 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -6,7 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d0fb76b1e9..d018eff7a2 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -6,7 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter' +import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { const ctx = new Context() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 63b2c700e2..6192396cab 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter' +import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index c8e725ed26..ed0900c4a1 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -7,7 +7,7 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** * Regression tests for the findings of the first architecture review diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index e040b39b77..ee2ce47699 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -14,7 +14,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents } from '../../../../scripts/gen-cordis-catalog' +import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 2fa1bfae27..57422e7719 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers } from '../src/index' -import type { SessionEvent } from '../src/index' +import { interruptedTurnClosers } from '../src/index.ts' +import type { SessionEvent } from '../src/index.ts' /** * Unit coverage for the crash-recovery closer synthesis. The persistence diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 51fbdbd187..b01b498dff 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -4,7 +4,7 @@ import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' -import { assemble, type AssembledResult } from './assemble' +import { assemble, type AssembledResult } from './assemble.ts' /** * Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index f576831e2c..1abbebc060 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import LlmService, { LlmError } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' -import { assemble } from './assemble' +import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ type Behavior = diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 678bb409fd..fa30226ddf 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -5,7 +5,7 @@ import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { Config } from '@deepseek-ai/dsh-llm-pi-ai' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { assemble, type AssembledResult } from './assemble' +import { assemble, type AssembledResult } from './assemble.ts' /** * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro across all diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index bd09afff99..63f9f90456 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' -import { assemble } from './assemble' +import { assemble } from './assemble.ts' /** Scripted SSE responses, one per request (OpenAI chat-completions shape). */ interface MockServer { diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index aa2a8080d5..1df70f9c9a 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -6,9 +6,9 @@ import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format' -import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract' -import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract' +import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts' +import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' let root: string const dirs: string[] = [] diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index af35c6c709..2bc9c59643 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -6,9 +6,9 @@ import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' -import { openDatabase, scanRows, type EventRow } from '../src/schema' -import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract' -import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract' +import { openDatabase, scanRows, type EventRow } from '../src/schema.ts' +import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 36356f3689..a0f0e7bfa0 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionPersistence } from '../src/index' +import type { SessionPersistence } from '../src/index.ts' /** A backend under test plus its teardown. */ export interface ContractBackend { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 7eab088deb..431d02b4cb 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -30,8 +30,8 @@ import { describe, expect, it } from 'vitest' import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '../src/index' -import { meta, oneTurnLog } from './contract' +import type { SessionPersistence } from '../src/index.ts' +import { meta, oneTurnLog } from './contract.ts' /** * The backend-specific capabilities the orchestration suite needs beyond the diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index cc7da9a41c..4e4cf67822 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -5,9 +5,9 @@ import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-sess import { SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix, type PersistenceBackend, type StoredPrefix, -} from '../src/index' -import { runPersistenceContract, meta, oneTurnLog } from './contract' -import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract' +} from '../src/index.ts' +import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' /** The durable store shape: materialized sessions only (no lazy entries). */ type MemoryStore = Map diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index e81f3729a0..925881273a 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -14,7 +14,7 @@ import { loadReplayScript, name, parseSessionLog, -} from '../src/index' +} from '../src/index.ts' /** * Unit tests for the replay llm/stream plugin. These drive the listener through diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts index 72d82b539c..bd5e0f7f91 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/support/ui-stdio/tests/ui-stdio.spec.ts @@ -5,7 +5,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { createStdioChat, type Config, type StdioRuntime } from '../src/index' +import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts' /** * Unit tests for the stdio UI plugin. They drive the REAL plugin body diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index c8e7920669..7a02837fca 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import * as acpAgent from '../src/index' +import * as acpAgent from '../src/index.ts' /** * In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition: diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 8089894ba1..e9ebca8d62 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { AgentId } from '@deepseek-ai/dsh-agent' -import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' /** * End-to-end bridge specs over an in-memory transport: a real diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 6149d35227..9d82fe7533 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -6,7 +6,7 @@ import { harnessBlockToAcpContent, promptHasUnsupportedContent, turnEndToStopReason, -} from '../src/codec' +} from '../src/codec.ts' describe('turnEndToStopReason', () => { // The SDK rejects an unknown stopReason, so this must be total over every diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 79d18612cc..ac092d9d16 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' import { AgentId } from '@deepseek-ai/dsh-agent' -import { makeBridgeHarness, textResponse } from './harness' +import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { let storageDir: string diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 1d31769202..69c935139d 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' describe('acp bridge — demux & config edges', () => { let storageDir: string diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 4b41b013bd..4f6b5ac17a 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -30,8 +30,8 @@ import { type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' -import * as AcpPlugin from '../src/index' -import { type AcpConfig } from '../src/index' +import * as AcpPlugin from '../src/index.ts' +import { type AcpConfig } from '../src/index.ts' /** A scripted mock adapter (mirrors the agent-loop test adapter). */ class MockAdapter extends LlmAdapter { diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index d0da3e5cd6..a87fa49a9e 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { AgentId } from '@deepseek-ai/dsh-agent' -import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness' +import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ function messageText(updates: CapturedUpdate[]): string { diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index d93f45a4b9..ca11934046 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { AgentId } from '@deepseek-ai/dsh-agent' -import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness' +import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Text of the agent_message_chunk updates scoped to one session id. */ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string { diff --git a/packages/ui/acp/tests/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts index fec9ebcacd..3dcb4c760f 100644 --- a/packages/ui/acp/tests/properties.spec.ts +++ b/packages/ui/acp/tests/properties.spec.ts @@ -19,7 +19,7 @@ import fc from 'fast-check' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' -import { streamSessionEventUpdate } from '../src/index' +import { streamSessionEventUpdate } from '../src/index.ts' const LEGAL_UPDATE_KINDS = new Set([ 'agent_message_chunk', diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 34271a3350..30cbd17c40 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -3,7 +3,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools' -import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index' +import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts' /** Collect the updates a single event produces (no presenter → generic fallback). */ function updatesFor(event: SessionEvent): SessionNotification['update'][] { diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 7a032d0f2f..7634602a35 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -12,7 +12,7 @@ import { textResponse, toolCallResponse, type BridgeHarness, -} from './harness' +} from './harness.ts' /** Boilerplate: initialize + create one session, returning its id. */ async function newSession(h: BridgeHarness, clientCapabilities: Record = {}): Promise { diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index a5dc1fbf90..f72de0a1da 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { AgentId } from '@deepseek-ai/dsh-agent' -import * as stdioAgent from '../src/index' +import * as stdioAgent from '../src/index.ts' /** * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it From fa9438bf161a4960145d7a9e28c8ea76382162b9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 22 Jun 2026 09:03:28 +0800 Subject: [PATCH 27/40] docs: update rewriteRelativeImportExtensions to current rfc --- .../rfc/implemented/process/2026-06-17-ts-build-config.md | 5 +++-- scripts/verify-node-next-types.ts | 8 ++++---- tsconfig.json | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md index fdcc85aa38..a45962c70e 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -17,7 +17,7 @@ Validation found several concrete technical issues and possible routes: - `tsdown` uses `oxc` to transform TypeScript, which is not the same behavior as `tsc`. - Bundled `.d.ts` emitted by `tsdown` conflicts with Cordis' internal relative module augmentation shape. - - The tsc output is affected by `allowImportingTsExtensions`, so we need to ensure that generated `.js` files do not import `.ts` files and generated `.d.ts` files do not contain extensionless relative imports. Therefore, in-package relative imports use explicit `.ts` specifiers in TypeScript source and `rewriteRelativeImportExtensions` rewrites those specifiers to `.js` in emitted JS. + - The tsc output is affected by `allowImportingTsExtensions`, so we need to ensure that generated `.js` files do not import `.ts` files and generated `.d.ts` files keep explicit relative specifiers that NodeNext/Node16 accepts. Therefore, in-package relative imports use explicit `.ts` specifiers in TypeScript source and `rewriteRelativeImportExtensions` rewrites those specifiers to `.js` in emitted JS. - Bundled `.js` emitted by `tsdown` is not the same behavior as per-file `.js` emitted by `tsc -b`, such as decorator transform behavior. - `vendor/*/src`, examples, tests, and scripts cannot all be plain-included in one root strict program. - Directly typechecking `vendor/*/src` under the root strict config triggers many type errors outside this project's ownership. @@ -39,6 +39,7 @@ In-package relative imports use explicit `.ts` specifiers. `pnpm run typecheck` runs build mode over the root `tsconfig.json`. - The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. +- The root no-emit project disables `rewriteRelativeImportExtensions`; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. The command orchestration shape is: @@ -66,7 +67,7 @@ Build responsibilities are clearer: - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. -- `pnpm run verify-node-next-types` scans built declarations for extensionless relative specifiers, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. +- `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts index 0f2e392666..7883a855c3 100644 --- a/scripts/verify-node-next-types.ts +++ b/scripts/verify-node-next-types.ts @@ -47,7 +47,7 @@ function workspacePackages(): WorkspacePackage[] { const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g const hasExtension = /\.[^/.]+$/ -function extensionlessRelativeSpecifiers(): string[] { +function relativeSpecifiersMissingExtensions(): string[] { const errors: string[] = [] const files = [ ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }), @@ -88,9 +88,9 @@ function linkPackage(pkg: WorkspacePackage, nodeModules: string): void { } const packages = workspacePackages() -const badSpecifiers = extensionlessRelativeSpecifiers() +const badSpecifiers = relativeSpecifiersMissingExtensions() if (badSpecifiers.length > 0) { - console.error('verify-node-next-types: declaration files still contain extensionless relative specifiers.') + console.error('verify-node-next-types: declaration files still contain relative specifiers without file extensions.') console.error(badSpecifiers.join('\n')) process.exit(1) } @@ -129,7 +129,7 @@ try { strict: true, // Third-party SDK declarations can have their own lib-check noise under a // symlinked temp install. The explicit scan above owns our regression: - // extensionless relative specifiers in built declarations. + // relative specifiers without file extensions in built declarations. skipLibCheck: true, preserveSymlinks: true, noEmit: true, diff --git a/tsconfig.json b/tsconfig.json index a4c1a8245c..bc5aee0bb9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "rewriteRelativeImportExtensions": false }, "include": [ "examples/*/src/**/*.ts", From f393043b036dfa411f7c13655d178a690b5b1a64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:47:02 +0800 Subject: [PATCH 28/40] Add the ACP subagent backend: out-of-process delegation (PR3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first OUT-OF-PROCESS subagent backend, proving the seam generalizes past the in-process backends. @deepseek-ai/dsh-subagent-acp runs each child agent in a spawned subprocess, driven over the Agent Client Protocol as the CLIENT — the direction-inverted twin of the dsh-acp server bridge. Point the configured command at the acp-agent example and the harness talks to its own process. - Fresh process per run: start spawns, runs one ACP session (initialize → newSession → prompt), dispose kills the subprocess and awaits its exit. - Minimal client stub: advertises no fs/terminal; accumulates agent_message_chunk text as the result output; auto-answers session/request_permission by a configured policy (reject default / allow). No start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); ignores request.parent; injects only `subagents`. - StopReason mapping (end_turn→completed, cancelled→aborted, …); result resolves error/aborted on a child failure, never rejects (seam contract). - Security: credential-shaped ambient env vars are scrubbed; the child's own key is forwarded only via explicit config.env. A spawn-level error (ENOENT) is captured and raced against the ACP drive so a bad command settles error rather than crashing the parent. Testing designed at every tier: keyless integration drives a scripted mock ACP server subprocess (cancellation incl. the pre-newSession race and a torn-pipe-after-cancel, permission auto-answer, non-message updates, spawn failure, HMR, export shape) at 100% coverage; a with-key e2e drives the REAL acp-agent example process (PONG + real file write, verified on disk) — the harness driving itself. Snapshot coverage of an ACP child is deferred as TODO(acp-subagent-replay) (each child is its own process with its own replay). Stayed on @agentclientprotocol/sdk 0.25.1: the proposed 0.28.x bump only deprecates the stable ClientSideConnection/AgentSideConnection API this layer uses (33 sites incl. the server bridge), turning no-deprecated red across code this PR shouldn't rewrite — that fluent-API migration is its own follow-up. The backend needs nothing 0.28.x adds. This completes the subagent seam stack (PR1 interface → PR2 in-process → PR2.5 snapshot infra → PR3 ACP); the seam RFC moves to implemented/, amended. --- docs/architecture.md | 2 +- docs/core-data-structures/subagent.md | 2 +- docs/module-graph.md | 4 + docs/rfc/README.md | 3 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-22-acp-subagent-backend.md | 47 +++ .../2026-06-22-subagent-snapshot-replay.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 2 +- knip.json | 4 + packages/README.md | 2 + packages/subagent/README.md | 5 +- packages/subagent/subagent-acp/README.md | 69 ++++ packages/subagent/subagent-acp/package.json | 39 +++ packages/subagent/subagent-acp/src/index.ts | 90 +++++ packages/subagent/subagent-acp/src/run.ts | 292 ++++++++++++++++ .../subagent-acp/tests/mock-acp-server.ts | 150 +++++++++ .../subagent-acp/tests/subagent-acp.e2e.ts | 110 ++++++ .../subagent-acp/tests/subagent-acp.spec.ts | 315 ++++++++++++++++++ packages/subagent/subagent-acp/tsconfig.json | 30 ++ packages/subagent/subagent/README.md | 2 +- packages/subagent/tool-subagent/README.md | 2 +- pnpm-lock.yaml | 25 ++ tsconfig.build.json | 3 +- 23 files changed, 1192 insertions(+), 12 deletions(-) rename docs/rfc/{proposed => implemented}/feature/2026-06-21-subagent-capability-seam.md (94%) create mode 100644 docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md create mode 100644 packages/subagent/subagent-acp/README.md create mode 100644 packages/subagent/subagent-acp/package.json create mode 100644 packages/subagent/subagent-acp/src/index.ts create mode 100644 packages/subagent/subagent-acp/src/run.ts create mode 100644 packages/subagent/subagent-acp/tests/mock-acp-server.ts create mode 100644 packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts create mode 100644 packages/subagent/subagent-acp/tests/subagent-acp.spec.ts create mode 100644 packages/subagent/subagent-acp/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 612e73ddce..216ed949b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` -**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. +**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. ### Loop lifecycle (session / turn / step) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index ec58de91bf..c64d370ff2 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -2,7 +2,7 @@ The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent RFC](../rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) diff --git a/docs/module-graph.md b/docs/module-graph.md index 0724130ced..95308a5207 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -60,6 +60,9 @@ graph TD agent-core --> system-prompt agent-core --> tool-bash agent-core --> tools + subagent-acp --> agent + subagent-acp --> llm + subagent-acp --> subagent subagent-mock --> agent subagent-mock --> llm subagent-mock --> subagent @@ -108,6 +111,7 @@ graph TD | `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `subagent-acp` | `agent`, `llm`, `subagent` | | `subagent-mock` | `agent`, `llm`, `subagent` | | `subagent-spawn` | `agent`, `llm`, `session`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 87b6456ab8..3ec7b7e5dc 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -44,7 +44,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | -| [Subagent capability seam](proposed/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | ### Simplification @@ -83,6 +82,8 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | +| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | +| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md similarity index 94% rename from docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md rename to docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 71a277ff5b..f733daca7b 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -1,8 +1,8 @@ # RFC: Subagent capability seam -Status: proposed +Status: implemented -> **Implementation status:** PR1 (this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer) is the first of three PRs. The two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`) and the out-of-process `dsh-subagent-acp` backend land in PR2 and PR3. Status stays `proposed` until all three ship; the file moves to `implemented/feature/` then, amended to describe what actually landed. +> **Implementation status:** shipped across four PRs. PR1 landed this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; PR2 the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); PR2.5 the nested-agent snapshot infrastructure (see [Per-session snapshot replay for nested agents](../testing/2026-06-22-subagent-snapshot-replay.md)); PR3 the out-of-process `dsh-subagent-acp` backend (see [ACP subagent backend](2026-06-22-acp-subagent-backend.md)). The design below is amended to describe what actually landed. ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md new file mode 100644 index 0000000000..6be0f6746b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -0,0 +1,47 @@ +# RFC: ACP subagent backend (out-of-process delegation) + +Status: implemented + +## Problem + +The subagent seam ([the seam RFC](2026-06-21-subagent-capability-seam.md)) was built so multiple backends coexist by name on `ctx.subagents`. The in-process backends (`-spawn`/`-fork`) run a child as a second `Agent` on the SAME cordis context — cheap, but the child shares the parent's process, model client, and tools. The seam's whole point was to also support an OUT-OF-PROCESS child reached over a protocol, proving the abstraction generalizes across a process boundary. This RFC adds the first such backend: an Agent Client Protocol (ACP) client. + +## Decision + +`@deepseek-ai/dsh-subagent-acp` registers a `SubagentProvider` that runs each child agent in a SPAWNED SUBPROCESS, driven over ACP as the *client*. It is the direction-inverted twin of the existing server-side bridge `@deepseek-ai/dsh-acp` (the ACP *agent*): the bridge ANSWERS `initialize`/`newSession`/`prompt`; this backend CALLS them and IMPLEMENTS the `Client` callbacks (`sessionUpdate`, `requestPermission`). Pointing the configured spawn command at the `acp-agent` example makes the harness talk to its own process. + +### Fresh process per run + +Each `start` spawns a new child, runs exactly one ACP session (`initialize` → `newSession` → `prompt`), and `dispose` kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape. Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need. + +### Minimal client stub + +The client advertises NO optional capabilities (no `fs`, no `terminal`): the child self-serves file/terminal access in its own process. `session/update` notifications are consumed — the backend accumulates `agent_message_chunk` text as the result output and ignores the rest (thoughts, tool-call cards) in this cut, which surfaces only the child's final answer. `session/request_permission` is auto-answered by a configured policy (`reject` declines every prompt, `allow` approves via the first allow-shaped option) — the first cut surfaces no prompt to a human. Proxying `fs`/`terminal` back to the parent (a shared-workspace mode) remains future work, as the seam RFC noted. + +### No start-time capabilities + +The provider's `capabilities` are all `false`. An out-of-process child cannot honor the parent's `maxDepth` (it has no access to `parent.options.subagentDepth`) or `toolFilter` (it owns its own tool registry), and the first cut does not implement `outputSchema`. The service rejects a request needing any of them before `start` runs. The backend injects only `subagents` (not `ctx.agents`) and ignores `request.parent`. + +### StopReason mapping + +ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `max_tokens`→`max-tokens`, `refusal`→`refusal`, `cancelled`→`aborted`, `max_turn_requests`→`error` (no clean equivalent — the task did not finish), unknown→`error`. A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested); `result` never rejects on a child-level failure, per the seam contract. + +### SDK version: stayed on 0.25.1 + +The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this PR has no business rewriting. That cross-cutting connection-API migration is its own PR, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up. + +### Security: scrubbed child environment + +The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. + +## Testing + +Designed at every tier the backend touches, per the AGENTS.md "design test infrastructure up front" rule: + +- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via `run.cancel()` and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling `aborted`; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling `error`; HMR provider cleanup; and the namespace export shape. 100% per-file coverage. +- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e. +- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [PR2.5](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. + +## Future providers + +The same out-of-process spawn/prompt/stream/cancel shape generalizes to other transports named in the seam RFC — A2A, the Codex app-server, and the Claude Code Agent SDK — each a sibling provider registered by name. The ACP backend is the proof that the seam supports the boundary; those are mechanically similar. diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 422c8d0301..acb29e401d 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -11,7 +11,7 @@ It was built for ONE session per process, and that assumption is wired into two - **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa). - **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log in the same cwd bucket, so the child's transcript was silently dropped. -This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam RFC](../../proposed/feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This RFC is that stacked follow-up. +This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This RFC is that stacked follow-up. ## Decision diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 2455981ba8..cc0db091dc 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -46,7 +46,7 @@ The genuine risks of collapsing the two ids into one (the case AGAINST this prop - **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes. -- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.) +- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.) - **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong. diff --git a/knip.json b/knip.json index aaf0e105d3..67d99a861d 100644 --- a/knip.json +++ b/knip.json @@ -40,6 +40,10 @@ "packages/subagent/subagent-spawn": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/subagent/subagent-acp": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/packages/README.md b/packages/README.md index 7cf32781bf..e5e66c63a7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -42,6 +42,7 @@ dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provid dsh-subagent-mock ← dsh-subagent (scripted provider for tests) dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-process fresh child + shared run driver) dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log) +dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP) dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool) dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) @@ -78,6 +79,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | | `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent (+ the shared in-process run driver) | (registers on `ctx.subagents`) | | `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | +| `subagent-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (registers on `ctx.subagents`) | | `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `brand/` | `util` | Type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 582172dfd1..1cffff1cb1 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -7,8 +7,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | | `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | +| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` and the out-of-process `subagent-acp` backends ship here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. -The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). +The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md new file mode 100644 index 0000000000..03990a7369 --- /dev/null +++ b/packages/subagent/subagent-acp/README.md @@ -0,0 +1,69 @@ +# @deepseek-ai/dsh-subagent-acp + +The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name. + +It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process". + +## What it does + +`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. + +**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC). + +Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend: +- injects only `subagents` (no `ctx.agents`); +- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); +- ignores `request.parent`. + +## Config + +| Key | Type | Default | Notes | +|---|---|---|---| +| `providerName` | string | `acp` | Registry name on `ctx.subagents`. | +| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). | +| `args` | string[] | `[]` | Arguments passed to `command`. | +| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. | +| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. | +| `env` | Record | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. | + +```yaml +- id: subagent-acp + name: '@deepseek-ai/dsh-subagent-acp' + config: + providerName: acp + command: node + args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml'] + permission: reject + env: + DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY +``` + +## StopReason mapping + +ACP `StopReason` → harness `SubagentStopReason`: + +| ACP | harness | +|---|---| +| `end_turn` | `completed` | +| `max_tokens` | `max-tokens` | +| `refusal` | `refusal` | +| `cancelled` | `aborted` | +| `max_turn_requests` | `error` (no clean equivalent; the task did not finish) | +| _(unknown)_ | `error` | + +A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested) — `result` never rejects on a child-level failure, per the seam contract. + +## Environment scrub + +Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded to the child by default — the parent harness's own secrets must not leak into a spawned process implicitly. The child's OWN credentials are supplied explicitly via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. + +## Testing + +- **Keyless** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key. +- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips without `DEEPSEEK_API_KEY`. + +`TODO(acp-subagent-replay)`: snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC. + +## Plugin export shape + +Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json new file mode 100644 index 0000000000..ee9c55a169 --- /dev/null +++ b/packages/subagent/subagent-acp/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-subagent-acp", + "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.25.1", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts new file mode 100644 index 0000000000..66f254b831 --- /dev/null +++ b/packages/subagent/subagent-acp/src/index.ts @@ -0,0 +1,90 @@ +/** + * The out-of-process ACP subagent backend: registers a {@link SubagentProvider} + * on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven + * over the Agent Client Protocol (ACP) as the client. The parent process is the + * ACP client; the child is any ACP agent (point the configured command at the + * `acp-agent` example to "talk to our own process"). + * + * Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share + * this cordis context — it is a separate process with its own session, model + * client, and tools. So this backend injects only `subagents` (no `agents`), + * advertises NO start-time capabilities (an out-of-process child cannot enforce + * the parent's depth/tool-filter), and ignores `request.parent`. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default + * export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`, + * so a stray default would drop the namespace — see docs/postmortem/0001). + * + * @module @deepseek-ai/dsh-subagent-acp + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts' + +export const name = 'subagent-acp' +export const inject = ['subagents'] + +/** Config: how to spawn and drive the child ACP agent process. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `acp`). */ + providerName: string + /** The executable to spawn for each run (the child ACP agent). */ + command: string + /** Arguments passed to {@link command}. */ + args: string[] + /** + * Working directory for the child process and its ACP session. Defaults to + * the parent process's cwd when omitted. + */ + cwd?: string + /** + * How to auto-answer the child's `session/request_permission` prompts: + * `reject` (default — decline every prompt) or `allow` (approve via the first + * allow-shaped option). The first cut surfaces no prompt to a human. + */ + permission: PermissionPolicy + /** + * Extra environment variables for the child process — e.g. the child + * harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed + * copy of the parent env, so an explicit key here reaches the child while + * ambient secrets do not leak implicitly. + */ + env: Record +} + +export const Config: z = z.object({ + providerName: z.string().default('acp'), + command: z.string().required(), + args: z.array(z.string()).default([]), + cwd: z.string(), + permission: z.union(['allow', 'reject'] as const).default('reject'), + env: z.dict(z.string()).default({}), +}) + +/** + * The ACP provider. Advertises NO start-time capabilities: an out-of-process + * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects + * a request needing any of them before `start` runs). + */ +class AcpProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + + constructor(readonly name: string, private readonly config: Config) {} + + start(request: SubagentStartRequest) { + const spec: AcpRunSpec = { + command: this.config.command, + args: this.config.args, + cwd: this.config.cwd ?? process.cwd(), + permission: this.config.permission, + env: this.config.env, + } + return startAcpRun(request, spec) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new AcpProvider(config.providerName, config)) +} diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts new file mode 100644 index 0000000000..18f20c53c1 --- /dev/null +++ b/packages/subagent/subagent-acp/src/run.ts @@ -0,0 +1,292 @@ +/** + * The out-of-process ACP subagent run driver. Spawns a child agent as a + * subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the + * CLIENT, drives one session to completion, and shapes the result into a + * {@link SubagentResult}. The mirror image of the server-side bridge in + * `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP + * *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we + * IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`). + * + * One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly + * one ACP session, and `dispose` kills the subprocess and awaits its exit. + * Persistent-process pooling is a future optimization (see the RFC). + * + * TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a + * distinct replay shape — each child is its own PROCESS with its own + * single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own + * sessions-root + fixture), unlike the in-process per-session keying in + * `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a + * scripted mock ACP server subprocess, and the with-key e2e drives the real + * `acp-agent` example. See the ACP-subagent-backend RFC. + * + * @module @deepseek-ai/dsh-subagent-acp/run + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { Readable, Writable } from 'node:stream' +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent as AcpAgent, + type Client, + type ContentBlock as AcpContentBlock, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type StopReason, +} from '@agentclientprotocol/sdk' +import { AgentId } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +/** + * How the client answers a child's `session/request_permission`. The first cut + * does not surface permission prompts to a human, so every request is + * auto-answered by this fixed policy: + * + * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child + * that asks before a side effect does not get to take it. + * - `allow` — approve every prompt by selecting its first `allow_*` option (or, + * if none is offered, `cancelled`). Use when the child is trusted to act. + */ +export type PermissionPolicy = 'allow' | 'reject' + +/** Resolved spawn spec for an ACP child process (no defaults — see Config). */ +export interface AcpRunSpec { + /** The executable to spawn (the child ACP agent). */ + command: string + /** Arguments passed to {@link command}. */ + args: string[] + /** Working directory for the child process AND its ACP session `cwd`. */ + cwd: string + /** How to auto-answer the child's permission prompts. */ + permission: PermissionPolicy + /** + * Extra environment variables to ADD for the child (e.g. the child harness's + * `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see + * {@link buildChildEnv}. A value here is forwarded even if its name matches + * the credential-scrub pattern (an explicit opt-in for the child's own creds). + */ + env: Record +} + +/** + * Credential-shaped ambient env vars are NOT forwarded to the child by default + * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a + * spawned process implicitly). Same pattern as the bash executor. The child + * agent needs its OWN credentials to reach a model — those are supplied + * explicitly via {@link AcpRunSpec.env}, which is layered on top AFTER the + * scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental + * `AWS_SECRET_ACCESS_KEY` does not. + */ +export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ +export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + } + return { ...env, ...extra } +} + +/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */ +export function acpStopReason(reason: StopReason): SubagentStopReason { + switch (reason) { + case 'end_turn': + return 'completed' + case 'max_tokens': + return 'max-tokens' + case 'refusal': + return 'refusal' + case 'cancelled': + return 'aborted' + // `max_turn_requests` (the child hit its turn-request budget) has no direct + // harness equivalent and means the task did NOT finish cleanly — surface it + // as a generic failure so the consumer maps it to an isError result rather + // than reporting a partial answer as success. + case 'max_turn_requests': + return 'error' + // ACP StopReason is a closed wire union, but a future SDK could add a + // variant; treat an unknown terminal reason as a failure (never silently + // 'completed'). + default: + return 'error' + } +} + +/** Collect the text of an ACP content block (non-text blocks contribute nothing). */ +export function acpContentText(content: AcpContentBlock): string { + return content.type === 'text' ? content.text : '' +} + +/** Translate the harness prompt blocks into ACP prompt blocks (text only). */ +export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] { + const blocks: AcpContentBlock[] = [] + for (const block of prompt) { + if (block.type === 'text') blocks.push({ type: 'text', text: block.text }) + } + return blocks +} + +/** Resolve once the child process exits (any code/signal); immediate if gone. */ +function waitForExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise(resolve => child.once('exit', () => { resolve() })) +} + +/** + * Start an out-of-process ACP child for `request` and return a {@link SubagentRun}. + * + * Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, + * and drives one session: `initialize` → `newSession` → `prompt`. The accumulated + * `agent_message_chunk` text is the result output; the prompt's terminal + * `StopReason` maps to the stop reason. `result` never REJECTS on a child-level + * failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per + * the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the + * subprocess and awaits its exit (quiescent teardown). + */ +export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun { + const id = AgentId(randomUUID()) + + // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP + // response channel, stderr = INHERIT so the child's diagnostics surface on the + // parent's stderr (no separate capture to drain — we don't fold child stderr + // into the result; the seam reports only output + stop reason). + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: buildChildEnv(spec.env), + stdio: ['pipe', 'pipe', 'inherit'], + }) + // A spawn-level failure (e.g. ENOENT for a bad command) is emitted as an + // `error` event, NOT a thrown exception — without a listener Node treats it as + // an unhandled error and crashes the parent. Capture it into a promise the + // result path races, so a bad command settles `error` like any child failure. + const spawnFailed = new Promise((resolve) => { + child.once('error', (err) => { resolve(err) }) + }) + + // Accumulate the child's streamed assistant text — the SubagentResult output. + const output: string[] = [] + // `cancelled` records that a cancel was requested (signal or cancel()), so a + // run torn down before the prompt resolves settles `aborted` rather than the + // generic error mapping. + let cancelled = false + + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + const update = params.update + if (update.sessionUpdate === 'agent_message_chunk') { + output.push(acpContentText(update.content)) + } + // Other updates (thoughts, tool calls, plans) are consumed but not + // surfaced in this cut — the subagent returns only its final answer. + return Promise.resolve() + }, + requestPermission(params: RequestPermissionRequest): Promise { + // Auto-answer by the configured policy. `allow` selects the first + // allow-shaped option the child offered; if it offered none (or we + // reject), answer `cancelled` so the child does not proceed. + if (spec.permission === 'allow') { + const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always') + if (allow !== undefined) { + return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } }) + } + } + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + + const conn = new ClientSideConnection( + makeClient, + ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ), + ) + + let sessionId: string | undefined + const requestCancel = (): void => { + cancelled = true + // Best-effort: tell the child to cancel the in-flight turn. Swallows a + // rejection — the session may not exist yet, or the pipe may be gone; the + // dispose path kills the process regardless. If the session has NOT been + // created yet (cancel raced ahead of `newSession`), the `cancelled` flag + // alone carries it: the result path re-checks the flag after each await and + // settles `aborted` without running the prompt. The `.catch` swallow is + // defensive for a narrow transport race (child gone mid-send) — v8-ignored + // because dispose kills the process regardless, so it can't be hit in tests. + /* v8 ignore next */ + if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ }) + } + const onAbort = (): void => { requestCancel() } + request.signal?.addEventListener('abort', onAbort, { once: true }) + + const result: Promise = (async (): Promise => { + // The accumulated child text as harness ContentBlocks (empty array when the + // child streamed nothing). Read at every return so a partial answer survives + // a later cancel/error. + const collectOutput = (): ContentBlock[] => { + const text = output.join('') + return text.length > 0 ? [{ type: 'text', text }] : [] + } + try { + // An already-aborted request never runs the child. + if (request.signal?.aborted) { + cancelled = true + return { output: [], stopReason: 'aborted' } + } + // Race the ACP drive against a spawn failure: a bad command never speaks + // ACP, so `initialize` would hang forever — the spawn `error` event is the + // only signal, and a rejected race settles the run `error` via the catch. + const driveAcp = async (): Promise => { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + // Advertise NO optional client capabilities (no fs, no terminal): the + // child self-serves in its own process. + clientCapabilities: {}, + }) + const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) + sessionId = session.sessionId + // A cancel that raced ahead of `newSession` set `cancelled` but could not + // send `session/cancel` (no session id yet). Honor it here: settle + // `aborted` without ever issuing the prompt, rather than running the child + // to completion and ignoring the cancel. + if (cancelled) return { output: collectOutput(), stopReason: 'aborted' } + const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) }) + return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } + } + return await Promise.race([ + driveAcp(), + spawnFailed.then((err): SubagentResult => { throw err }), + ]) + } catch { + // The seam contract: result resolves (never rejects) on a child-level + // failure. A spawn/transport/RPC error becomes an error/aborted result — + // `aborted` if a cancel was requested (the failure is the cancellation + // surfacing as a torn pipe / rejected RPC), else a genuine `error`. + return { output: collectOutput(), stopReason: cancelled ? 'aborted' : 'error' } + } + })() + + return { + id, + result, + cancel(_reason?: string): void { + requestCancel() + }, + async dispose(): Promise { + request.signal?.removeEventListener('abort', onAbort) + // Kill the subprocess and AWAIT its exit (quiescent teardown — dispose + // must reach quiescence, not merely request it). SIGTERM first; the child + // is our own short-lived ACP agent, so a graceful term is enough. Guard + // the kill: the process may already be gone. + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM') + } + await waitForExit(child) + }, + } +} diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts new file mode 100644 index 0000000000..e383bbe8eb --- /dev/null +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -0,0 +1,150 @@ +/** + * A minimal mock ACP AGENT, run as a subprocess, for the keyless + * `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is + * fully scripted by environment variables — no model, no network: + * + * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` + * (`end_turn` default, or `max_tokens`/`refusal`/…). + * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for + * a `session/cancel`), to exercise the client's cancel path. + * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` + * before answering, to exercise the client's auto-answer. + * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` + * handler is in flight (it has streamed its chunk). A test + * polls for this file to cancel on a CONDITION rather than + * an arbitrary timeout (subprocess cold-start is variable). + * + * It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the + * child process the ACP backend drives. Kept as a `.ts` run under tsx by the + * spec (which passes its own tsconfig), mirroring how the snapshot harness boots + * the real example. + * + * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server + */ + +import { randomUUID } from 'node:crypto' +import { existsSync, writeFileSync } from 'node:fs' +import { Readable, Writable } from 'node:stream' +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent, + type CancelNotification, + type AuthenticateRequest, + type InitializeRequest, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type StopReason, +} from '@agentclientprotocol/sdk' + +const TEXT = process.env.MOCK_TEXT ?? 'mock child answer' +const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason +const HANG = process.env.MOCK_HANG === '1' +const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1' +const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1' +const THOUGHT = process.env.MOCK_THOUGHT === '1' +const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1' +const READY_FILE = process.env.MOCK_READY_FILE +// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks +// until GO appears — letting a test cancel mid-newSession deterministically. +const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined + ? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO } + : undefined + +function makeAgent(conn: AgentSideConnection): Agent { + // Pending cancel resolver for the HANG path: a `session/cancel` resolves the + // prompt with `cancelled`. + let resolveCancel: ((reason: StopReason) => void) | undefined + + return { + initialize(_params: InitializeRequest): Promise { + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } }, + authMethods: [], + }) + }, + async newSession(_params: NewSessionRequest): Promise { + // Optionally signal "newSession reached" and block until released, so a + // test can cancel DURING newSession (the early-cancel race window) on a + // condition rather than a timeout. + if (NEWSESSION_GATE !== undefined) { + writeFileSync(NEWSESSION_GATE.ready, 'at-newSession') + while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10)) + } + return { sessionId: randomUUID() } + }, + authenticate(_params: AuthenticateRequest): Promise { + // No auth methods advertised; nothing to do. + return Promise.resolve() + }, + async prompt(params: PromptRequest): Promise { + if (WANT_PERMISSION) { + // Ask the client to approve before answering; honor its decision. Under + // MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy + // client finds no allow option and must fall back to cancelled. + const options = NO_ALLOW + ? [{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const }] + : [ + { optionId: 'yes', name: 'Allow', kind: 'allow_once' as const }, + { optionId: 'no', name: 'Reject', kind: 'reject_once' as const }, + ] + const decision = await conn.requestPermission({ + sessionId: params.sessionId, + toolCall: { toolCallId: 'mock-call', title: 'mock side effect' }, + options, + }) + if (decision.outcome.outcome === 'cancelled') { + return { stopReason: 'cancelled' } + } + } + // Optionally emit a NON-message update first (a thought), so the client's + // sessionUpdate sees an update it must consume-but-not-accumulate. + if (THOUGHT) { + await conn.sessionUpdate({ + sessionId: params.sessionId, + update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } }, + }) + } + // Stream the canned assistant text as one chunk. + await conn.sessionUpdate({ + sessionId: params.sessionId, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } }, + }) + // Signal "prompt is in flight" by touching the readiness file, so a test + // can wait on a CONDITION (file exists) rather than an arbitrary timeout + // before cancelling — deterministic regardless of subprocess cold-start. + if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ready') + if (HANG) { + // Never resolve on our own: wait for session/cancel to settle us. + return new Promise((resolve) => { + resolveCancel = (reason) => { resolve({ stopReason: reason }) } + }) + } + return { stopReason: STOP } + }, + cancel(_params: CancelNotification): Promise { + if (CRASH_ON_CANCEL) { + // Exit hard instead of answering — tears the ACP pipe, so the client's + // pending prompt REJECTS (exercises the backend's catch-while-cancelled + // path: a transport failure after a cancel settles `aborted`). + process.exit(1) + } + resolveCancel?.('cancelled') + return Promise.resolve() + }, + } +} + +new AgentSideConnection( + makeAgent, + ndJsonStream( + Writable.toWeb(process.stdout) as WritableStream, + Readable.toWeb(process.stdin) as ReadableStream, + ), +) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts new file mode 100644 index 0000000000..826ef198dd --- /dev/null +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -0,0 +1,110 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as acp from '../src/index.ts' + +/** + * With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP + * server. The backend spawns the real `acp-agent` example as a child PROCESS, + * speaks ACP to it over stdio, and the child runs the REAL model in its own + * process to answer a prompt. We verify the child's real answer comes back + * through the seam — the "talk to our own process" smoke the design called for. + * Key-gated (self-skips without DEEPSEEK_API_KEY). + * + * This is the out-of-process analogue of the in-process spawn e2e: there a + * parent agent on the same context drove a child; here the child is a separate + * process reached over ACP, proving the seam generalizes across the boundary. + */ + +// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). +const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url)) +const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** The ACP backend ignores the parent, but the seam requires one. */ +const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive our own acp-agent)', () => { + it('drives the real acp-agent example process to answer a prompt', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) + ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, binScript, exampleConfig], + cwd: workdir, + permission: 'reject', + // The child harness needs the key to reach the model; forward it + // explicitly (buildChildEnv scrubs ambient creds but keeps these extras). + env: { + ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, + ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, + TSX_TSCONFIG_PATH: repoTsconfig, + }, + }) + + const run = ctx.subagents.start('acp', { + prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }], + parent: fakeParent, + }) + const result = await run.result + await run.dispose() + + // The real child process completed its turn and streamed a real answer back + // across the ACP boundary. + expect(result.stopReason).toBe('completed') + const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('') + expect(text.length).toBeGreaterThan(0) + expect(text.toUpperCase()).toContain('PONG') + }, 180_000) + + it('drives the child to do real file work via its own bash tool', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) + ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, binScript, exampleConfig], + cwd: workdir, + // The child needs to act (run bash), so approve its permission prompts. + permission: 'allow', + env: { + ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, + ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, + TSX_TSCONFIG_PATH: repoTsconfig, + }, + }) + + const run = ctx.subagents.start('acp', { + prompt: [{ type: 'text', text: + 'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt ' + + 'in the current directory. Then reply DONE.' }], + parent: fakeParent, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + // Verify the WORLD: the child process actually wrote the file in its cwd. + const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') + expect(proof).toContain('ACP_CHILD_WAS_HERE') + }, 180_000) +}) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts new file mode 100644 index 0000000000..ac74ace7e8 --- /dev/null +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as acp from '../src/index.ts' +import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, toAcpPrompt } from '../src/run.ts' + +/** + * Keyless integration tests for the ACP subagent backend. Each spawns a REAL + * subprocess — the scripted mock ACP server (tests/mock-acp-server.ts) — and + * drives it through the REAL backend over real ACP JSON-RPC stdio, so the + * connection setup, the client callbacks, the prompt round-trip, the stop-reason + * mapping, cancellation, and quiescent disposal are all exercised end to end. + * No model, no key. + */ + +const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */ +const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent + +interface SetupEnv { + /** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */ + [key: string]: string +} + +/** + * Mount the ACP backend pointed at the mock server, scripted by `mockEnv`. + * `permission` selects the backend's auto-answer policy. + */ +async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + permission, + // The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets + // tsx resolve @deepseek-ai/* from a child cwd outside the repo. + env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig }, + }) + return ctx +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +/** + * Poll until `file` exists (the mock touches it once its prompt is in flight), + * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the + * subprocess cold-start under tsx is variable, and a fixed sleep both flakes and + * slows the suite. Fails loud if the child never signals readiness. + */ +async function waitForFile(file: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(file)) { + if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`) + await new Promise(r => setTimeout(r, 10)) + } +} + +describe('acpStopReason', () => { + it('maps each ACP stop reason to the harness vocabulary', () => { + expect(acpStopReason('end_turn')).toBe('completed') + expect(acpStopReason('max_tokens')).toBe('max-tokens') + expect(acpStopReason('refusal')).toBe('refusal') + expect(acpStopReason('cancelled')).toBe('aborted') + expect(acpStopReason('max_turn_requests')).toBe('error') + }) + + it('treats an unknown terminal reason as an error', () => { + expect(acpStopReason('something-new' as never)).toBe('error') + }) +}) + +describe('acpContentText / toAcpPrompt', () => { + it('extracts text from a text content block, empty for non-text', () => { + expect(acpContentText({ type: 'text', text: 'hi' })).toBe('hi') + // A non-text ACP content block (e.g. an image) contributes no text. + expect(acpContentText({ type: 'image', data: 'x', mimeType: 'image/png' })).toBe('') + }) + + it('keeps text prompt blocks and drops non-text ones', () => { + expect(toAcpPrompt([{ type: 'text', text: 'a' }])).toEqual([{ type: 'text', text: 'a' }]) + // A non-text harness block (e.g. reasoning) is dropped from the ACP prompt. + expect(toAcpPrompt([{ type: 'text', text: 'a' }, { type: 'reasoning', text: 'think' }])) + .toEqual([{ type: 'text', text: 'a' }]) + }) +}) + +describe('buildChildEnv', () => { + it('drops credential-shaped ambient vars but keeps the explicit extras', () => { + process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me' + try { + const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' }) + // The credential-shaped ambient var is scrubbed. + expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined() + // The explicitly-supplied key survives (an opt-in for the child's creds). + expect(env.DEEPSEEK_API_KEY).toBe('explicit') + // A normal ambient var is forwarded. + expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) + expect(env.PATH).toBe(process.env.PATH) + } finally { + delete process.env.DSH_ACP_TEST_SECRET_TOKEN + } + }) +}) + +describe('dsh-subagent-acp', () => { + it('drives a child process to completion and returns its streamed output', async () => { + const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'do X' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('hello from acp child') + await run.dispose() + }) + + it('maps a max_tokens stop reason', async () => { + const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + await run.dispose() + }) + + it('maps a refusal stop reason', async () => { + const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('refusal') + await run.dispose() + }) + + it('cancelling a running child settles aborted (session/cancel via run.cancel)', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-')) + const readyFile = join(tmp, 'ready') + try { + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + // Wait until the child's prompt is in flight (condition, not a sleep), + // then cancel — so we exercise the mid-run session/cancel path. + await waitForFile(readyFile) + run.cancel('test') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('settles aborted without running the child when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + const ctx = await setup({ MOCK_TEXT: 'never seen' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => { + // Gate the child at newSession: it signals `ready` and blocks until `go`. + // We cancel WHILE newSession is pending (sessionId still undefined, so the + // backend cannot send session/cancel) — the `cancelled` flag alone must + // settle the run aborted after newSession resolves, never issuing the prompt. + const tmp = mkdtempSync(join(tmpdir(), 'acp-early-')) + const ready = join(tmp, 'ready') + const go = join(tmp, 'go') + try { + const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) // newSession is now in flight, sessionId undefined + run.cancel('early') // sets cancelled; cannot send session/cancel yet + writeFileSync(go, 'go') // let newSession resolve + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('bridges the request signal to a session/cancel mid-run', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-')) + const readyFile = join(tmp, 'ready') + try { + const controller = new AbortController() + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }) + await waitForFile(readyFile) + controller.abort() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => { + const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject') + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + // The child asked permission, the backend rejected, the child returned cancelled. + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('auto-approves a permission prompt under the allow policy', async () => { + const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow') + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('approved answer') + await run.dispose() + }) + + it('falls back to cancelled under the allow policy when the child offers no allow option', async () => { + // The child asks permission but offers ONLY reject-shaped options, so an + // allow-policy client finds nothing to select and must answer cancelled. + const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow') + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('consumes a non-message update (a thought) without adding it to the output', async () => { + // The child streams an agent_thought_chunk before its answer; the backend + // must consume it but NOT include it in the result output. + const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + // Only the message text, NOT the thought. + expect(text(result.output)).toBe('final answer') + await run.dispose() + }) + + it('resolves error (not reject) when the spawn command does not exist', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: '/nonexistent/acp-agent-binary', + args: [], + permission: 'reject', + env: {}, + }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + // The seam contract: a child-level failure resolves error, never rejects. + expect(result.stopReason).toBe('error') + await run.dispose() + }) + + it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => { + // The child hangs, we cancel, and instead of answering the child exits hard + // — the pending prompt RPC rejects. With a cancel already requested, the + // backend's catch path must settle `aborted` (the failure is the cancel + // surfacing as a torn pipe), not `error`. + const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-')) + const ready = join(tmp, 'ready') + try { + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) + run.cancel('crash it') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('advertises no start-time capabilities (out-of-process child)', async () => { + const ctx = await setup() + const provider = ctx.subagents.getProvider('acp')! + expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) + expect(ctx.subagents.list()).toEqual(['acp']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in acp).toBe(false) + expect(acp.name).toBe('subagent-acp') + expect(acp.inject).toEqual(['subagents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(acp) as Record + expect(unwrapped).toBe(acp) + expect(unwrapped.name).toBe('subagent-acp') + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json new file mode 100644 index 0000000000..77a7b76e5f --- /dev/null +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index de55315a79..57862ca8ab 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -34,6 +34,6 @@ Unlike the bash seam (one executor per context, second load throws), **multiple ## Scope (first cut) -The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). +The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). See `src/types.ts` for the full contracts. diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 22b66fbf80..1bb48f29ff 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -16,4 +16,4 @@ This plugin binds to **exactly one** provider (`Config.provider`). The model see `execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success. -Background / poll collection is deferred (see the [RFC](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes. +Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b262257b14..4003119c65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,31 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.25.1 + version: 0.25.1(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-fork: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index 4f0528961d..8b3010b3fe 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -36,6 +36,7 @@ { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-spawn" }, - { "path": "./packages/subagent/subagent-fork" } + { "path": "./packages/subagent/subagent-fork" }, + { "path": "./packages/subagent/subagent-acp" } ] } From 475c68cbe8804972fc5ca761b023077c76644b11 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:36:35 +0800 Subject: [PATCH 29/40] docs: sync package cookbook with build config --- docs/cookbook/adding-a-package.md | 22 ++++++++++--------- .../2026-06-20-package-hierarchy.md | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 593a0a93ba..3c9cdb07d5 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -5,16 +5,19 @@ The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verifie ## 1. Create the package ``` -packages// +packages/// package.json # copy from packages/core/tools, adjust name/description/deps - tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib/types, - # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery - # if you use Config, + ../ for each dsh dependency) + tsconfig.json # extends ../../../tsconfig.base.json, rootDir src, + # outDir lib/types, references: ../../../vendor/cosmokit, + # ../../../vendor/cordis (+ ../../../vendor/schemastery if + # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes ``` +Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. + package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. @@ -23,13 +26,12 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | File | Change | |---|---| -| `tsconfig.base.json` | add `"@deepseek-ai/dsh-": ["./packages//src"]` to `paths` | -| `tsconfig.json` | add `{ "path": "./packages/" }` to `references` | -| `tsconfig.build.json` | add `{ "path": "./packages/" }` to `references` | -| `scripts/publint-all.ts` | add `'packages/'` to the array | +| `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | +| `tsconfig.json` | add `{ "path": "./packages//" }` to `references` | +| `tsconfig.build.json` | add `{ "path": "./packages//" }` to `references` | | `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | -Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. +Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`. ## 3. Decide the package topology @@ -41,7 +43,7 @@ For a swappable capability, split interface / implementation / consumer into sep pnpm install # registers the workspace pnpm run constraints && pnpm run typecheck && pnpm run lint pnpm run test:coverage # 100% per-file over src (types.ts exempt) -pnpm run build && pnpm run knip && pnpm run publint +pnpm run build && pnpm run hygiene ``` Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see AGENTS.md. diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md index 60e295e767..8b198ed9c6 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -51,7 +51,7 @@ packages/ The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead: -- `tsconfig.base.json` and `tsconfig.typecheck.json` each map every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of 18 per-package entries. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the `paths` map via the TypeScript JSONC API rather than stripping comments by hand for exactly this reason.) +- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.) - `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. - `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). From 6801130f8cc79c5001c458e52150e622024ac840 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:39:30 +0800 Subject: [PATCH 30/40] Bound ACP dispose with SIGKILL escalation; skip spawn when pre-aborted (Codex review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lifecycle findings from the review: - A (blocker): dispose() could hang forever. It only sent SIGTERM and awaited exit, with no escalation — a child that traps SIGTERM (or our acp-agent if it doesn't quiesce on stdin EOF) would wedge dispose, stranding tool-subagent's finally cleanup and orphaning child-owned work (e.g. bash subprocesses). dispose now: ends stdin (graceful ACP close so the child can flush + exit), SIGTERM, then escalates to SIGKILL if it doesn't exit within a grace period (DEFAULT_DISPOSE_GRACE_MS, injectable via spec.disposeGraceMs), awaiting the certain exit. Mirrors the bash executor's bounded teardown. Regression test drives a SIGTERM-trapping mock subprocess and asserts dispose returns promptly — proven to hang (red) without the escalation. - B: an already-aborted request still spawned the configured binary. startAcpRun now returns an inert already-aborted run BEFORE spawning, so a pre-cancelled request launches nothing. Test points the command at `touch ` and asserts the sentinel never appears. The dispose regression test exposed (via systematic-debugging) that the child must signal trap-armed readiness before the test cancels — a bare timeout raced the trap install and the default SIGTERM handler killed the child, making the guard a no-op. The mock now touches its ready file once the trap is in place and the test waits on that condition. The `cancelled` flag moved onto a holder object so TS control-flow doesn't narrow the catch-time read to always-false. --- packages/subagent/subagent-acp/src/run.ts | 71 ++++++++++++++----- .../subagent-acp/tests/mock-acp-server.ts | 14 ++++ .../subagent-acp/tests/subagent-acp.spec.ts | 68 +++++++++++++++--- 3 files changed, 126 insertions(+), 27 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 18f20c53c1..612fe87074 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -70,8 +70,17 @@ export interface AcpRunSpec { * the credential-scrub pattern (an explicit opt-in for the child's own creds). */ env: Record + /** + * Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in + * {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS}; + * a test injects a small value to exercise the escalation without a long wait. + */ + disposeGraceMs?: number } +/** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + /** * Credential-shaped ambient env vars are NOT forwarded to the child by default * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a @@ -133,6 +142,9 @@ export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] { /** Resolve once the child process exits (any code/signal); immediate if gone. */ function waitForExit(child: ChildProcess): Promise { + // Already-exited fast path: dispose guards on exitCode before calling, so in + // tests the child is always still alive here. + /* v8 ignore next */ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() return new Promise(resolve => child.once('exit', () => { resolve() })) } @@ -151,6 +163,18 @@ function waitForExit(child: ChildProcess): Promise { export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun { const id = AgentId(randomUUID()) + // A request already aborted before it starts never spawns the child at all — + // return an inert run that settled `aborted`, rather than launching the + // configured binary just to tear it down. `dispose`/`cancel` are no-ops. + if (request.signal?.aborted) { + return { + id, + result: Promise.resolve({ output: [], stopReason: 'aborted' }), + cancel(_reason?: string): void { /* nothing was started */ }, + dispose(): Promise { return Promise.resolve() }, + } + } + // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP // response channel, stderr = INHERIT so the child's diagnostics surface on the // parent's stderr (no separate capture to drain — we don't fold child stderr @@ -172,8 +196,11 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su const output: string[] = [] // `cancelled` records that a cancel was requested (signal or cancel()), so a // run torn down before the prompt resolves settles `aborted` rather than the - // generic error mapping. - let cancelled = false + // generic error mapping. Held on a mutable object so the async closures that + // set it (the abort listener) and the IIFE that reads it don't fight TS's + // control-flow narrowing of a bare `let` (which would type the catch-time read + // as always-`false`). + const flags = { cancelled: false } const makeClient = (_agent: AcpAgent): Client => ({ sessionUpdate(params: SessionNotification): Promise { @@ -209,7 +236,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su let sessionId: string | undefined const requestCancel = (): void => { - cancelled = true + flags.cancelled = true // Best-effort: tell the child to cancel the in-flight turn. Swallows a // rejection — the session may not exist yet, or the pipe may be gone; the // dispose path kills the process regardless. If the session has NOT been @@ -233,11 +260,6 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return text.length > 0 ? [{ type: 'text', text }] : [] } try { - // An already-aborted request never runs the child. - if (request.signal?.aborted) { - cancelled = true - return { output: [], stopReason: 'aborted' } - } // Race the ACP drive against a spawn failure: a bad command never speaks // ACP, so `initialize` would hang forever — the spawn `error` event is the // only signal, and a rejected race settles the run `error` via the catch. @@ -254,7 +276,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // send `session/cancel` (no session id yet). Honor it here: settle // `aborted` without ever issuing the prompt, rather than running the child // to completion and ignoring the cancel. - if (cancelled) return { output: collectOutput(), stopReason: 'aborted' } + if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } @@ -267,7 +289,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // failure. A spawn/transport/RPC error becomes an error/aborted result — // `aborted` if a cancel was requested (the failure is the cancellation // surfacing as a torn pipe / rejected RPC), else a genuine `error`. - return { output: collectOutput(), stopReason: cancelled ? 'aborted' : 'error' } + return { output: collectOutput(), stopReason: flags.cancelled ? 'aborted' : 'error' } } })() @@ -279,14 +301,29 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) - // Kill the subprocess and AWAIT its exit (quiescent teardown — dispose - // must reach quiescence, not merely request it). SIGTERM first; the child - // is our own short-lived ACP agent, so a graceful term is enough. Guard - // the kill: the process may already be gone. - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGTERM') + // Reach quiescence, not merely request it (dispose must AWAIT the child + // actually stopping). If the child is already gone, nothing to do. + if (child.exitCode !== null || child.signalCode !== null) return + // 1. Graceful: end the ACP request stream (stdin EOF). Our own acp-agent + // disposes its fiber on stdin 'end' — flushing persistence and stopping + // child-owned work (e.g. bash subprocesses) — then exits, which the + // server bridge's connection-close quiesce path drives. A child that + // ignores EOF is handled by the signal escalation below. + child.stdin.end() + // 2. SIGTERM, then escalate to SIGKILL if it does not exit within the + // grace period — a child that traps SIGTERM must not wedge dispose + // forever (the seam requires bounded quiescence). Race the exit against + // a grace timer; on timeout, SIGKILL and await the (now-certain) exit. + child.kill('SIGTERM') + const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS + const exited = await Promise.race([ + waitForExit(child).then(() => true), + new Promise(resolve => setTimeout(() => { resolve(false) }, graceMs).unref()), + ]) + if (!exited) { + child.kill('SIGKILL') + await waitForExit(child) } - await waitForExit(child) }, } } diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index e383bbe8eb..8260051f63 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -148,3 +148,17 @@ new AgentSideConnection( Readable.toWeb(process.stdin) as ReadableStream, ), ) + +// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process +// neither quiesces on EOF nor dies on the graceful signal — exercising the +// backend dispose path's SIGKILL escalation. Without this the process exits +// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so +// a test waits for that CONDITION before disposing (the trap must be in place, +// not merely the process spawned — otherwise SIGTERM hits the default handler). +if (process.env.MOCK_TRAP_SIGTERM === '1') { + process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ }) + // Keep the event loop alive (a bare timer) so nothing else lets it exit. + setInterval(() => { /* stay alive until SIGKILL */ }, 1000) + if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed') +} + diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index ac74ace7e8..0e4604e638 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, toAcpPrompt } from '../src/run.ts' +import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL @@ -159,15 +159,63 @@ describe('dsh-subagent-acp', () => { } }) - it('settles aborted without running the child when the signal is already aborted', async () => { - const controller = new AbortController() - controller.abort() - const ctx = await setup({ MOCK_TEXT: 'never seen' }) - const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }) - const result = await run.result - expect(result.stopReason).toBe('aborted') - expect(result.output).toEqual([]) - await run.dispose() + it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => { + // A pre-aborted request must not even launch the configured binary. Point + // the command at one that would create a sentinel file if it ever ran, and + // assert the sentinel never appears. + const tmp = mkdtempSync(join(tmpdir(), 'acp-preabort-')) + const sentinel = join(tmp, 'spawned') + try { + const controller = new AbortController() + controller.abort() + const run = startAcpRun( + { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }, + // `touch ` — runs only if the process is actually spawned. + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} }, + ) + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + // cancel/dispose on the inert run are safe no-ops. + run.cancel('noop') + await run.dispose() + // The binary was never launched — no sentinel. + expect(existsSync(sentinel)).toBe(false) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => { + // The child traps SIGTERM and keeps its event loop alive, so a graceful + // term alone would hang dispose forever. With a short grace, dispose must + // escalate to SIGKILL and return once the process is actually gone. + const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-')) + const ready = join(tmp, 'trap-armed') + try { + const spec: AcpRunSpec = { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + disposeGraceMs: 150, + } + const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) + // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a + // sleep) — otherwise SIGTERM races the trap install and the default handler + // terminates the child, never exercising the escalation. + await waitForFile(ready) + // Don't await result (the child hangs). Dispose must still return promptly + // via the SIGKILL escalation — bound it so a regression (no escalation) + // fails loud instead of hanging the suite. + await expect(Promise.race([ + run.dispose(), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — no SIGKILL escalation')) }, 4000) }), + ])).resolves.toBeUndefined() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } }) it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => { From 4565161c64b86a7db497106a76f0d5dd81e2e119 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:11:17 +0800 Subject: [PATCH 31/40] Give the ACP child an EOF window to quiesce before SIGTERM (Codex review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispose() ended stdin and sent SIGTERM in the same tick, so the child's EOF-driven quiesce had no window to run. The real acp-agent has no SIGTERM handler in a normal session — it flushes persistence and stops child-owned work via the server bridge's connection-close path (conn.closed → per-agent dispose → final session/flush), driven by stdin EOF, NOT by a signal. A prompt response can resolve from a turn/end before that post-turn flush lands, so the child still owes durable work when dispose runs; a same-tick default SIGTERM terminated it mid-flush, orphaning child-owned bash and dropping the flush. dispose now waits for the child's natural exit after stdin EOF first, then escalates SIGTERM (grace), then SIGKILL — a three-tier ladder. Add an `exitsWithin` helper for the bounded waits. Regression coverage: a new mock mode (MOCK_FLUSH_ON_EOF) flushes a marker asynchronously on EOF then self-exits; the tier-1 test asserts the marker lands (proven RED on the same-tick-SIGTERM ordering — child killed mid-flush). MOCK_IGNORE_EOF covers the middle tier (ignores EOF, dies on default SIGTERM); the existing MOCK_TRAP_SIGTERM test covers the SIGKILL tier. --- packages/subagent/subagent-acp/src/run.ts | 47 ++++++++------ .../subagent-acp/tests/mock-acp-server.ts | 38 ++++++++++++ .../subagent-acp/tests/subagent-acp.spec.ts | 61 +++++++++++++++++++ 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 612fe87074..c78e8ae08d 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -149,6 +149,15 @@ function waitForExit(child: ChildProcess): Promise { return new Promise(resolve => child.once('exit', () => { resolve() })) } +/** Resolve `true` if the child exits within `ms`, `false` on timeout. */ +function exitsWithin(child: ChildProcess, ms: number): Promise { + return Promise.race([ + waitForExit(child).then(() => true), + // `.unref()` so a pending grace timer never keeps the parent's loop alive. + new Promise(resolve => setTimeout(() => { resolve(false) }, ms).unref()), + ]) +} + /** * Start an out-of-process ACP child for `request` and return a {@link SubagentRun}. * @@ -304,26 +313,26 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // Reach quiescence, not merely request it (dispose must AWAIT the child // actually stopping). If the child is already gone, nothing to do. if (child.exitCode !== null || child.signalCode !== null) return - // 1. Graceful: end the ACP request stream (stdin EOF). Our own acp-agent - // disposes its fiber on stdin 'end' — flushing persistence and stopping - // child-owned work (e.g. bash subprocesses) — then exits, which the - // server bridge's connection-close quiesce path drives. A child that - // ignores EOF is handled by the signal escalation below. - child.stdin.end() - // 2. SIGTERM, then escalate to SIGKILL if it does not exit within the - // grace period — a child that traps SIGTERM must not wedge dispose - // forever (the seam requires bounded quiescence). Race the exit against - // a grace timer; on timeout, SIGKILL and await the (now-certain) exit. - child.kill('SIGTERM') const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS - const exited = await Promise.race([ - waitForExit(child).then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, graceMs).unref()), - ]) - if (!exited) { - child.kill('SIGKILL') - await waitForExit(child) - } + // 1. Graceful: end the ACP request stream (stdin EOF) and let the child + // quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal + // session — it tears down via the server bridge's connection-close path + // (conn.closed → per-agent dispose → final session/flush), driven by the + // stdin EOF, NOT by a signal. A prompt response can resolve from a + // turn/end BEFORE that post-turn flush lands, so the child still has + // durable work owed when dispose runs. Give the EOF-driven quiesce a real + // window to finish (flush persistence, stop child-owned bash) and EXIT; + // sending SIGTERM in the same tick would default-terminate it mid-flush. + child.stdin.end() + if (await exitsWithin(child, graceMs)) return + // 2. SIGTERM, then escalate to SIGKILL if it still does not exit within the + // grace period — a child that ignores EOF and traps SIGTERM must not + // wedge dispose forever (the seam requires bounded quiescence). + child.kill('SIGTERM') + if (await exitsWithin(child, graceMs)) return + // 3. Force-kill and await the (now-certain) exit. + child.kill('SIGKILL') + await waitForExit(child) }, } } diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 8260051f63..b30492258a 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -14,6 +14,18 @@ * handler is in flight (it has streamed its chunk). A test * polls for this file to cancel on a CONDITION rather than * an arbitrary timeout (subprocess cold-start is variable). + * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat + * (simulating the real acp-agent's EOF-driven + * quiesce+flush), then touches this path and exits ON ITS + * OWN — no signal. Stands in for a child whose durable + * flush completes only if dispose gives EOF a real window + * before escalating to SIGTERM. + * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare + * timer) but leave SIGTERM at its DEFAULT handler, so the + * child ignores the graceful EOF window yet still dies on + * SIGTERM — exercising dispose's middle tier (exit during + * the SIGTERM grace, before the SIGKILL escalation). It + * touches MOCK_READY_FILE once the keepalive is armed. * * It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the * child process the ACP backend drives. Kept as a `.ts` run under tsx by the @@ -50,6 +62,7 @@ const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1' const THOUGHT = process.env.MOCK_THOUGHT === '1' const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1' const READY_FILE = process.env.MOCK_READY_FILE +const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF // When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks // until GO appears — letting a test cancel mid-newSession deterministically. const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined @@ -162,3 +175,28 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') { if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed') } +// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on +// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to +// "flush", then touch the marker and exit ON OUR OWN — no signal involved. A +// dispose that sends SIGTERM in the same tick as the EOF (no graceful window) +// default-terminates this process before the beat completes, so the marker is +// missing; a dispose that waits for natural exit first lets the flush land. +if (FLUSH_ON_EOF !== undefined) { + process.stdin.on('end', () => { + setTimeout(() => { + writeFileSync(FLUSH_ON_EOF, 'flushed') + process.exit(0) + }, 150) + }) +} + +// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF but leave SIGTERM at +// its DEFAULT handler — the child ignores the graceful EOF window yet still dies +// on SIGTERM, exercising dispose's middle tier (exit during the SIGTERM grace, +// before the SIGKILL escalation). Touch the ready file once the keepalive is +// armed, so a test disposes on that condition rather than a timeout. +if (process.env.MOCK_IGNORE_EOF === '1') { + setInterval(() => { /* stay alive past EOF; default SIGTERM still kills us */ }, 1000) + if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed') +} + diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 0e4604e638..96b749218d 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -218,6 +218,67 @@ describe('dsh-subagent-acp', () => { } }) + it('dispose gives the child an EOF window to quiesce before escalating (graceful flush)', async () => { + // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears + // down on connection close, NOT on a signal) — and it has no SIGTERM handler. + // The mock models that: on stdin 'end' it takes a beat to "flush", touches a + // marker, and exits on its own. dispose() must end stdin and WAIT for that + // natural exit before sending SIGTERM; a same-tick SIGTERM default-kills the + // child mid-flush and the marker never appears. + const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-')) + const ready = join(tmp, 'ready') + const flushed = join(tmp, 'flushed') + try { + const spec: AcpRunSpec = { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + // MOCK_HANG so the prompt never resolves on its own — we tear down a live + // child. MOCK_FLUSH_ON_EOF is the marker the child writes iff its EOF + // quiesce was allowed to finish. + env: { MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, MOCK_FLUSH_ON_EOF: flushed, TSX_TSCONFIG_PATH: repoTsconfig }, + } + const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) + // Wait until the child is fully booted with its prompt in flight (its ACP + // stdin reader is attached), so dispose's stdin EOF reaches a live child. + await waitForFile(ready) + await run.dispose() + // dispose returned via the natural-exit tier — the EOF-driven flush landed. + expect(existsSync(flushed)).toBe(true) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { + // A child that keeps its loop alive past stdin EOF (so the graceful window + // times out) but leaves SIGTERM at the default handler must die on the + // SIGTERM tier — dispose returns there, never reaching the SIGKILL tier. + const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-')) + const ready = join(tmp, 'ready') + try { + const spec: AcpRunSpec = { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + disposeGraceMs: 150, + } + const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) + await waitForFile(ready) + // Bound it: a regression (no SIGTERM tier, only EOF + SIGKILL) would still + // pass, but a hang would fail loud rather than stall the suite. + await expect(Promise.race([ + run.dispose(), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 4000) }), + ])).resolves.toBeUndefined() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => { // Gate the child at newSession: it signals `ready` and blocks until `go`. // We cancel WHILE newSession is pending (sessionId still undefined, so the From 3a67d0a8825a6d494ed7fdc8a6c6c401476d0044 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:54:11 +0800 Subject: [PATCH 32/40] docs: sync development CI gate docs --- docs/development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development.md b/docs/development.md index f2206d30c0..99f67e671d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -76,10 +76,10 @@ The GitHub workflow runs these gates on each pull request: - `pnpm run test:coverage` - `pnpm run test:snapshot` - `pnpm run build` -- `pnpm run knip && pnpm run publint` +- `pnpm run hygiene` - an echo-agent smoke test that checks the demo's tool call, tool result, and JSONL output -`pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints`; CI splits `pnpm run constraints` into its own earlier step, then runs `pnpm run knip && pnpm run publint` after `pnpm run build`. +`pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types`; CI also runs `pnpm run constraints` as an earlier fail-fast step, then runs the full hygiene script after `pnpm run build`. ## Daily commands From 2ff112962b10c0f97389c065a655002e9a2029d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:29:23 +0800 Subject: [PATCH 33/40] Widen the dispose EOF grace past nested-teardown headroom; prove the SIGTERM rung (Codex review round 3) Two round-3 findings: (A) The EOF-quiesce window reused the 3000ms SIGTERM grace, the SAME value as dsh-bash-local's own SIGTERM->SIGKILL grace. The child acp-agent's EOF teardown disposes its loop, which stops child-owned bash -- and a SIGTERM-trapping bash grandchild can hold that for up to ~3s before its own SIGKILL, then the child still owes a final flush. With both graces equal, the parent's SIGTERM fired exactly as the child reached its own SIGKILL+flush, cutting it off. Split the EOF grace into its own knob (disposeEofGraceMs, default 6000ms) that exceeds a single signal-grace of nested-teardown headroom. The child is an arbitrary ACP agent, so the value is a standalone generous default, NOT derived from any child's internals. Tier-1 test now uses a flush that outlasts the SIGTERM grace but fits the EOF grace, so it lands only because the EOF tier honors its own wider window (proven RED when tier 1 reuses the small SIGTERM grace). (B) The middle-tier (SIGTERM) test only asserted dispose returned in time, so an EOF->SIGKILL ladder with the rung removed would still pass. The mock's MOCK_IGNORE_EOF mode now installs a SIGTERM handler that touches an observable marker before exiting; SIGKILL is uncatchable, so removing the SIGTERM rung leaves the marker absent (proven RED). The test asserts the marker exists. --- packages/subagent/subagent-acp/src/run.ts | 30 +++++++++- .../subagent-acp/tests/mock-acp-server.ts | 54 +++++++++++------- .../subagent-acp/tests/subagent-acp.spec.ts | 57 +++++++++++++------ 3 files changed, 101 insertions(+), 40 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index c78e8ae08d..8aeb136f2d 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -70,6 +70,13 @@ export interface AcpRunSpec { * the credential-scrub pattern (an explicit opt-in for the child's own creds). */ env: Record + /** + * Grace period (ms) for the child's EOF-driven quiesce in + * {@link SubagentRun.dispose} — the window to flush persistence and tear down + * its OWN nested subprocesses before the parent escalates to a signal. Defaults + * to {@link DEFAULT_DISPOSE_EOF_GRACE_MS}; a test injects a small value. + */ + disposeEofGraceMs?: number /** * Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in * {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS}; @@ -78,6 +85,19 @@ export interface AcpRunSpec { disposeGraceMs?: number } +/** + * Default grace for the child's EOF-driven quiesce on dispose — the window for it + * to flush persistence and tear down its OWN nested subprocesses (which may run + * their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a + * signal. Deliberately LARGER than {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative + * child whose teardown is itself waiting on a signal-trapping grandchild (e.g. a + * bash subprocess in its own ~3s SIGTERM→SIGKILL grace) plus a final flush needs + * MORE than a single signal-grace of headroom, or the parent's SIGTERM cuts it off + * exactly as it reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, + * so this is a standalone generous default, NOT derived from any child's internals. + */ +export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 + /** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 @@ -313,6 +333,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // Reach quiescence, not merely request it (dispose must AWAIT the child // actually stopping). If the child is already gone, nothing to do. if (child.exitCode !== null || child.signalCode !== null) return + const eofGraceMs = spec.disposeEofGraceMs ?? DEFAULT_DISPOSE_EOF_GRACE_MS const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS // 1. Graceful: end the ACP request stream (stdin EOF) and let the child // quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal @@ -321,10 +342,13 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // stdin EOF, NOT by a signal. A prompt response can resolve from a // turn/end BEFORE that post-turn flush lands, so the child still has // durable work owed when dispose runs. Give the EOF-driven quiesce a real - // window to finish (flush persistence, stop child-owned bash) and EXIT; - // sending SIGTERM in the same tick would default-terminate it mid-flush. + // window — wider than a single signal-grace, since the child's own + // teardown may itself be awaiting a signal-trapping grandchild (a bash + // subprocess in its own SIGTERM→SIGKILL grace) plus a flush — and only + // escalate if it overruns. Sending SIGTERM in the same tick (or too soon) + // would default-terminate the child mid-flush, orphaning its nested work. child.stdin.end() - if (await exitsWithin(child, graceMs)) return + if (await exitsWithin(child, eofGraceMs)) return // 2. SIGTERM, then escalate to SIGKILL if it still does not exit within the // grace period — a child that ignores EOF and traps SIGTERM must not // wedge dispose forever (the seam requires bounded quiescence). diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index b30492258a..74ae340bde 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -15,17 +15,19 @@ * polls for this file to cancel on a CONDITION rather than * an arbitrary timeout (subprocess cold-start is variable). * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat - * (simulating the real acp-agent's EOF-driven - * quiesce+flush), then touches this path and exits ON ITS - * OWN — no signal. Stands in for a child whose durable - * flush completes only if dispose gives EOF a real window - * before escalating to SIGTERM. + * (MOCK_FLUSH_DELAY_MS, default 150) simulating the real + * acp-agent's EOF-driven quiesce+flush, then touches this + * path and exits ON ITS OWN — no signal. Stands in for a + * child whose durable flush completes only if dispose + * gives EOF a real window before escalating to SIGTERM. * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare - * timer) but leave SIGTERM at its DEFAULT handler, so the - * child ignores the graceful EOF window yet still dies on - * SIGTERM — exercising dispose's middle tier (exit during - * the SIGTERM grace, before the SIGKILL escalation). It - * touches MOCK_READY_FILE once the keepalive is armed. + * timer) but install a SIGTERM handler that exits (and, if + * MOCK_SIGTERM_FILE is set, touches it as an observable + * proof the SIGTERM rung fired). The child ignores the + * graceful EOF window yet dies cooperatively on SIGTERM — + * exercising dispose's middle tier (exit during the SIGTERM + * grace, before the SIGKILL escalation). Touches + * MOCK_READY_FILE once armed. * * It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the * child process the ACP backend drives. Kept as a `.ts` run under tsx by the @@ -177,26 +179,36 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') { // Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on // stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to -// "flush", then touch the marker and exit ON OUR OWN — no signal involved. A -// dispose that sends SIGTERM in the same tick as the EOF (no graceful window) -// default-terminates this process before the beat completes, so the marker is -// missing; a dispose that waits for natural exit first lets the flush land. +// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The +// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before +// the beat completes (no graceful window, or an EOF grace shorter than the +// flush) default-terminates this process and the marker is missing; a dispose +// that gives the EOF quiesce enough window first lets the flush land. if (FLUSH_ON_EOF !== undefined) { + const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') process.stdin.on('end', () => { setTimeout(() => { writeFileSync(FLUSH_ON_EOF, 'flushed') process.exit(0) - }, 150) + }, flushDelayMs) }) } -// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF but leave SIGTERM at -// its DEFAULT handler — the child ignores the graceful EOF window yet still dies -// on SIGTERM, exercising dispose's middle tier (exit during the SIGTERM grace, -// before the SIGKILL escalation). Touch the ready file once the keepalive is -// armed, so a test disposes on that condition rather than a timeout. +// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF +// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the +// child ignores the graceful EOF window yet dies cooperatively on SIGTERM, +// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the +// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an +// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle +// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs +// and the marker is missing. Touch READY_FILE once armed (a test waits on it). if (process.env.MOCK_IGNORE_EOF === '1') { - setInterval(() => { /* stay alive past EOF; default SIGTERM still kills us */ }, 1000) + const sigtermFile = process.env.MOCK_SIGTERM_FILE + process.on('SIGTERM', () => { + if (sigtermFile !== undefined) writeFileSync(sigtermFile, 'sigterm') + process.exit(0) + }) + setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000) if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed') } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 96b749218d..926319ad87 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -199,6 +199,10 @@ describe('dsh-subagent-acp', () => { cwd: process.cwd(), permission: 'reject', env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + // Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must + // burn the EOF window, then the SIGTERM window, then SIGKILL — keep each + // small so the whole ladder finishes well within the 4000ms bound. + disposeEofGraceMs: 150, disposeGraceMs: 150, } const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) @@ -218,13 +222,16 @@ describe('dsh-subagent-acp', () => { } }) - it('dispose gives the child an EOF window to quiesce before escalating (graceful flush)', async () => { + it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => { // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears // down on connection close, NOT on a signal) — and it has no SIGTERM handler. - // The mock models that: on stdin 'end' it takes a beat to "flush", touches a - // marker, and exits on its own. dispose() must end stdin and WAIT for that - // natural exit before sending SIGTERM; a same-tick SIGTERM default-kills the - // child mid-flush and the marker never appears. + // Its EOF teardown can itself await a signal-trapping grandchild (a bash + // subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window + // must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value. + // The mock models a flush that takes LONGER than the SIGTERM grace but well + // under the EOF grace: it lands only because tier 1 waits eofGraceMs, not + // graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the + // round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.) const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-')) const ready = join(tmp, 'ready') const flushed = join(tmp, 'flushed') @@ -235,16 +242,23 @@ describe('dsh-subagent-acp', () => { cwd: process.cwd(), permission: 'reject', // MOCK_HANG so the prompt never resolves on its own — we tear down a live - // child. MOCK_FLUSH_ON_EOF is the marker the child writes iff its EOF - // quiesce was allowed to finish. - env: { MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, MOCK_FLUSH_ON_EOF: flushed, TSX_TSCONFIG_PATH: repoTsconfig }, + // child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits + // the 2000ms EOF grace; the marker lands iff the EOF tier honored its own + // wider grace. + env: { + MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, + MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig, + }, + disposeEofGraceMs: 2000, + disposeGraceMs: 50, } const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) // Wait until the child is fully booted with its prompt in flight (its ACP // stdin reader is attached), so dispose's stdin EOF reaches a live child. await waitForFile(ready) await run.dispose() - // dispose returned via the natural-exit tier — the EOF-driven flush landed. + // dispose returned via the natural-exit tier — the EOF-driven flush landed + // despite taking longer than the SIGTERM grace. expect(existsSync(flushed)).toBe(true) } finally { rmSync(tmp, { recursive: true, force: true }) @@ -253,27 +267,38 @@ describe('dsh-subagent-acp', () => { it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { // A child that keeps its loop alive past stdin EOF (so the graceful window - // times out) but leaves SIGTERM at the default handler must die on the - // SIGTERM tier — dispose returns there, never reaching the SIGKILL tier. + // times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier + // — dispose returns there, never reaching the SIGKILL tier. The child touches + // a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if + // dispose had skipped the middle rung (EOF→SIGKILL) the handler would never + // run and the marker would be absent — making this a GENUINE middle-tier guard. const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-')) const ready = join(tmp, 'ready') + const sigterm = join(tmp, 'sigterm') try { const spec: AcpRunSpec = { command: process.execPath, args: ['--import', tsxLoader, mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, - disposeGraceMs: 150, + env: { + MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', + MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig, + }, + // Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM. + disposeEofGraceMs: 150, + disposeGraceMs: 2000, } const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) await waitForFile(ready) - // Bound it: a regression (no SIGTERM tier, only EOF + SIGKILL) would still - // pass, but a hang would fail loud rather than stall the suite. + // Bound it so a hang fails loud rather than stalling the suite. await expect(Promise.race([ run.dispose(), - new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 4000) }), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }), ])).resolves.toBeUndefined() + // The child caught SIGTERM and exited — proof the middle rung fired (not a + // jump straight to the uncatchable SIGKILL). + expect(existsSync(sigterm)).toBe(true) } finally { rmSync(tmp, { recursive: true, force: true }) } From 0c9ea3145f1afb25b67317713d770550b08073b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:27:38 +0800 Subject: [PATCH 34/40] Extract the shared in-process driver into dsh-subagent-inprocess (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared run driver lived inside dsh-subagent-spawn, so the spawn package carried fork-aware seeding logic and dsh-subagent-fork depended backward on dsh-subagent-spawn — the two in-process backends were not independent. Move the driver (startInProcessRun, depthOf, SubagentDepthError, InProcessRunOptions) into a new pure-library package @deepseek-ai/dsh-subagent-inprocess that registers nothing. spawn and fork now both depend only on that driver and neither knows about the other; spawn no longer re-exports it and fork no longer imports from spawn. Also wire BOTH backends in examples/coding-agent/cordis.yml (config-only): load dsh-subagent-spawn + dsh-subagent-fork + two dsh-tool-subagent instances with distinct toolNames (subagent → spawn, subagent_fork → fork), demonstrating that exposing multiple transports needs no code change. --- docs/module-graph.md | 17 ++-- examples/coding-agent/cordis.yml | 28 ++++-- packages/subagent/README.md | 5 +- packages/subagent/subagent-fork/package.json | 3 +- packages/subagent/subagent-fork/src/index.ts | 8 +- packages/subagent/subagent-fork/tsconfig.json | 2 +- .../subagent/subagent-inprocess/README.md | 28 ++++++ .../subagent/subagent-inprocess/package.json | 40 +++++++++ .../src/index.ts} | 21 ++--- .../tests/subagent-inprocess.spec.ts | 85 +++++++++++++++++++ .../subagent/subagent-inprocess/tsconfig.json | 30 +++++++ packages/subagent/subagent-spawn/README.md | 14 +-- packages/subagent/subagent-spawn/package.json | 5 +- packages/subagent/subagent-spawn/src/index.ts | 11 +-- .../tests/subagent-spawn.spec.ts | 2 +- .../subagent/subagent-spawn/tsconfig.json | 12 +-- pnpm-lock.yaml | 36 ++++++++ tsconfig.build.json | 1 + 18 files changed, 286 insertions(+), 62 deletions(-) create mode 100644 packages/subagent/subagent-inprocess/README.md create mode 100644 packages/subagent/subagent-inprocess/package.json rename packages/subagent/{subagent-spawn/src/in-process.ts => subagent-inprocess/src/index.ts} (91%) create mode 100644 packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts create mode 100644 packages/subagent/subagent-inprocess/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index 0724130ced..a91c5bed98 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -60,13 +60,13 @@ graph TD agent-core --> system-prompt agent-core --> tool-bash agent-core --> tools + subagent-inprocess --> agent + subagent-inprocess --> llm + subagent-inprocess --> session + subagent-inprocess --> subagent subagent-mock --> agent subagent-mock --> llm subagent-mock --> subagent - subagent-spawn --> agent - subagent-spawn --> llm - subagent-spawn --> session - subagent-spawn --> subagent tool-subagent --> agent tool-subagent --> llm tool-subagent --> subagent @@ -82,7 +82,9 @@ graph TD subagent-fork --> agent subagent-fork --> session subagent-fork --> subagent - subagent-fork --> subagent-spawn + subagent-fork --> subagent-inprocess + subagent-spawn --> subagent + subagent-spawn --> subagent-inprocess ``` | Package | Depends on | @@ -108,9 +110,10 @@ graph TD | `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | | `subagent-mock` | `agent`, `llm`, `subagent` | -| `subagent-spawn` | `agent`, `llm`, `session`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | -| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-spawn` | +| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` | +| `subagent-spawn` | `subagent`, `subagent-inprocess` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 136031062a..0347115cd5 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -57,17 +57,21 @@ Use the subagent tool to delegate a focused, self-contained subtask to a fresh child agent (it works in its own context and returns only - its final result) — give it a complete, standalone instruction. + its final result) — give it a complete, standalone instruction. Use + subagent_fork instead when the subtask needs THIS conversation's + context: the child inherits the log so far. Check the [exit code: N] marker on every command; investigate failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. -# The subagent seam + an in-process spawn backend + the model-facing `subagent` -# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The -# tool is bound to the `spawn` backend: a delegated task runs as a fresh child -# agent on this same process. (fork is available too — load dsh-subagent-fork -# and a second dsh-tool-subagent bound to it with a distinct toolName.) +# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf +# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh +# child) and fork (a child seeded with the parent's completed-turn prefix) are +# independent backends over the shared dsh-subagent-inprocess driver. Exposing +# both transports is pure config: load each backend, then load dsh-tool-subagent +# once per backend with a distinct toolName (the tool registry rejects a +# duplicate name) — no code change. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -76,7 +80,19 @@ config: providerName: spawn +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 582172dfd1..85ba55b626 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -5,10 +5,11 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | -| `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) | +| `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — | +| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other) and ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 44027b4b0c..3348a77dd0 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -23,7 +23,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-spawn": "^0.0.1", + "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 6c730e225e..02c1811d82 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -2,8 +2,10 @@ * The in-process FORK subagent backend: registers a {@link SubagentProvider} on * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a * prefix of the parent's session log — so the child inherits the parent's - * conversation context instead of starting fresh. Shares the run driver with - * `@deepseek-ai/dsh-subagent-spawn`; the only difference is the seed. + * conversation context instead of starting fresh. The run mechanics live in + * `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this + * backend just computes the seed. The spawn backend is an independent peer over + * the same driver. * * The seed boundary is the crux: at the moment a subagent tool's `execute` * runs, the parent's CURRENT turn is open and unbalanced (it holds the @@ -23,7 +25,7 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-spawn' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' export const inject = ['subagents', 'agents'] diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json index d05e0f6081..bf2abbe698 100644 --- a/packages/subagent/subagent-fork/tsconfig.json +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -27,7 +27,7 @@ "path": "../subagent" }, { - "path": "../subagent-spawn" + "path": "../subagent-inprocess" } ] } diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md new file mode 100644 index 0000000000..af6d5792a9 --- /dev/null +++ b/packages/subagent/subagent-inprocess/README.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-subagent-inprocess + +The shared **in-process subagent run driver**. A pure library (no provider, no registration) that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other. + +## What it exports + +### `startInProcessRun(ctx, request, options): SubagentRun` + +Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): + +1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); +2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited); +3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); +4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. + +`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. + +### `InProcessRunOptions` + +`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed. + +### `depthOf(agent): number` + +Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0). + +### `SubagentDepthError` + +Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json new file mode 100644 index 0000000000..c2f6896c62 --- /dev/null +++ b/packages/subagent/subagent-inprocess/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-subagent-inprocess", + "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-inprocess/src/index.ts similarity index 91% rename from packages/subagent/subagent-spawn/src/in-process.ts rename to packages/subagent/subagent-inprocess/src/index.ts index e121611697..d2840881af 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -1,15 +1,16 @@ /** - * The shared in-process subagent run driver. A subagent backend that runs the - * child as a child {@link Agent} on the SAME cordis context (`ctx.agents`) — - * the cheapest transport, reusing the agent factory's quiescent - * {@link AgentHandle} teardown. Both in-process backends use this: - * `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and - * `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the - * parent's log) differ ONLY in the `seed` they pass — everything downstream - * (drive the child, read its final output, map the stop reason, dispose) is - * identical and lives here. + * The shared in-process subagent run driver: run a child as a child + * {@link Agent} on the SAME cordis context (`ctx.agents`) — the cheapest + * transport, reusing the agent factory's quiescent {@link AgentHandle} + * teardown. The concrete in-process backends are thin shells over this driver, + * differing ONLY in the `seed` they pass (a fresh child vs. a child seeded with + * a prefix of the parent's log); everything downstream — drive the child, read + * its final output, map the stop reason, dispose — is identical and lives here. * - * @module @deepseek-ai/dsh-subagent-spawn/in-process + * This package owns no provider and registers nothing; it is a pure library the + * backend packages depend on, so neither backend needs to know about the other. + * + * @module @deepseek-ai/dsh-subagent-inprocess */ import { randomUUID } from 'node:crypto' diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts new file mode 100644 index 0000000000..7219e03988 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * Drives the shared in-process run driver DIRECTLY (no provider package), so the + * driver's own contract — depth read/cap, the one-shot drive, the result read — + * is covered independently of which backend (spawn/fork) calls it. The only + * mocked boundary is the model; the real agent loop, SubagentService, and + * dsh-invariants are mounted, so a malformed child session log fails the test. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('depthOf', () => { + it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => { + const { parent } = await setup([]) + expect(depthOf(parent)).toBe(0) + const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent + expect(depthOf(withDepth)).toBe(3) + }) +}) + +describe('startInProcessRun', () => { + it('drives a fresh child (no seed) to completion and returns its output', async () => { + const { ctx, parent } = await setup([textResponse('driver child answer')]) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('driver child answer') + expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + await run.dispose() + }) + + it('throws SubagentDepthError when the child would exceed maxDepth', async () => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) + .toThrow(SubagentDepthError) + }) + + it('seeds the child session when a seed is supplied', async () => { + // Drive the parent through one real turn, then seed the child with that + // completed-turn prefix — the child must SEE the parent's history but its + // result is scoped to its OWN events (not the seeded parent message). + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')]) + parent.send([{ type: 'text', text: 'parent q' }]) + await parent.whenIdle() + const seed = parent.session.events.slice() + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('seeded child reply') + const child = ctx.agents.get(run.id)! + // The child inherited the parent's prefix. + expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true) + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json new file mode 100644 index 0000000000..f90eba8f7e --- /dev/null +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 4e6e22f68d..97dfae9304 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -2,17 +2,11 @@ The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown. -It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed. +The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other. ## What it does -`start(request)` → -1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); -2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited); -3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); -4. reads the result: the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. - -`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. +`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities @@ -23,7 +17,3 @@ It also exports the **shared in-process run driver** (`startInProcessRun`) that | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `spawn`). | - -## Depth tracking - -Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. Read it with the exported `depthOf(agent)`. diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 184296f01a..359d91e962 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -20,10 +20,8 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -38,6 +36,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index bbfcc03719..2ea082e20a 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -5,9 +5,9 @@ * context). The cheapest transport, reusing the agent factory's quiescent * teardown. * - * The fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run - * driver ({@link startInProcessRun}) and differs ONLY in seeding the child with - * a prefix of the parent's log. + * The run mechanics live in `@deepseek-ai/dsh-subagent-inprocess` + * ({@link startInProcessRun}); this backend just passes NO seed (a fresh + * child). The fork backend is an independent peer over the same driver. * * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. * @@ -17,10 +17,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from './in-process.ts' - -export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts' -export type { InProcessRunOptions } from './in-process.ts' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' export const inject = ['subagents', 'agents'] diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index c57819d86a..ccfd6492f4 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -12,7 +12,7 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { depthOf, SubagentDepthError } from '../src/in-process.ts' +import { depthOf, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json index 5e6c9f9100..dc7b5cc8cd 100644 --- a/packages/subagent/subagent-spawn/tsconfig.json +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -17,17 +17,11 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../core/agent" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, { "path": "../subagent" + }, + { + "path": "../subagent-inprocess" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b262257b14..bd7133ca0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../subagent-inprocess '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn @@ -373,6 +376,36 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-inprocess: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-spawn: dependencies: schemastery: @@ -406,6 +439,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../subagent-inprocess '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/tsconfig.build.json b/tsconfig.build.json index 4f0528961d..d9b00156b3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -35,6 +35,7 @@ { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-inprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" } ] From 67c7ef791f323036ffc0a4e20fcc708b1edeb73b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:43:51 +0800 Subject: [PATCH 35/40] Apply the ts-build-config (lib/types) convention to the new subagent packages Master's #36 moved declaration output to lib/types (and types/exports/files point there). The merge applied that to all pre-existing packages, but the subagent backends introduced on this stack (subagent-inprocess, subagent-spawn, subagent-fork) still used the old lib/ layout. Bring them onto the new convention and add them to the single typecheck tsconfig.json references. --- packages/subagent/subagent-fork/package.json | 8 +++++--- packages/subagent/subagent-fork/tsconfig.json | 2 +- packages/subagent/subagent-inprocess/package.json | 8 +++++--- packages/subagent/subagent-inprocess/tsconfig.json | 2 +- packages/subagent/subagent-spawn/package.json | 8 +++++--- packages/subagent/subagent-spawn/tsconfig.json | 2 +- tsconfig.json | 5 ++++- 7 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 3348a77dd0..7b1c40c4f3 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json index bf2abbe698..bac12550af 100644 --- a/packages/subagent/subagent-fork/tsconfig.json +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index c2f6896c62..f3bd774554 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index f90eba8f7e..4cb435d4fb 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 359d91e962..087371ded2 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json index dc7b5cc8cd..219bf2a0c9 100644 --- a/packages/subagent/subagent-spawn/tsconfig.json +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/tsconfig.json b/tsconfig.json index caf73078cd..3cb9a7e4a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,6 +45,9 @@ { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, - { "path": "./packages/subagent/tool-subagent" } + { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-inprocess" }, + { "path": "./packages/subagent/subagent-spawn" }, + { "path": "./packages/subagent/subagent-fork" } ] } From 87231863984cd63a52e782849229c62bf0ce0852 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:32:10 +0800 Subject: [PATCH 36/40] Honor an already-aborted signal in the subagent tool bridge (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addEventListener('abort') does not fire for a signal already aborted before the listener is added, so a parent step cancelled before the subagent tool ran would never reach the child — the tool leaned on each provider re-checking request.signal itself, leaving the bridge's own claim incomplete for any provider that relies on run.cancel(). Re-check exec.signal.aborted right after registering and cancel explicitly. Regression test uses a spy provider that only reacts to cancel() (never inspects the signal); proven to hang without the fix (result never settles) and settle aborted with it. --- packages/subagent/tool-subagent/src/index.ts | 5 +++ .../tool-subagent/tests/tool-subagent.spec.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index a19e09db98..05490127ea 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -136,6 +136,11 @@ export function apply(ctx: Context, config: Config): void { // aborted while the child is in flight, cancel the child too. const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal?.addEventListener('abort', onAbort, { once: true }) + // `addEventListener` does NOT fire for a signal already aborted before this + // line, so a step cancelled before the tool ran would never reach the + // child. Cancel explicitly in that case — the bridge must honor an + // already-aborted signal, not lean on each provider re-checking it. + if (exec.signal?.aborted) run.cancel('parent step aborted') try { const result = await run.result diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 521fdfba50..dda4e7c3d0 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -282,6 +282,43 @@ describe('dsh-tool-subagent', () => { expect(result.isError).toBe(true) }) + it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => { + // `addEventListener('abort')` does not fire for a signal already aborted + // before the listener is added, so a step cancelled before the tool ran + // would never reach the child unless the bridge re-checks `signal.aborted`. + // A provider that leans only on the abort EVENT (this spy never inspects + // request.signal) proves the bridge itself must cancel. + const cancelled = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => { + let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void + const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) + return { + id: AgentId('spy-child'), + result, + cancel: () => { + cancelled() + resolveResult({ output: [], stopReason: 'aborted' }) + }, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'spy' }) + + const controller = new AbortController() + controller.abort() // already aborted BEFORE the tool runs + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) + expect(cancelled).toHaveBeenCalledTimes(1) + expect(result.isError).toBe(true) + }) + it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) From 083af62785fea35fd16b21965fcf453dcd3d36f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:57:38 +0800 Subject: [PATCH 37/40] Settle ACP cancel without the child's cooperation; preserve flattened errors (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings on the ACP backend: Blocking: cancel() only sent session/cancel, so a child that ignores the notify or wedges the prompt left result hung forever — the model-facing tool awaits result before its finally disposes, so the parent cancellation hung and the child stayed alive, violating the SubagentRun.cancel() contract (result settles aborted). The result path now races the ACP drive against a cancelSettled promise that requestCancel resolves, so result settles aborted the instant a cancel is requested, regardless of the child. dispose() still kills+reaps the process. New MOCK_IGNORE_CANCEL mock mode (receives cancel, never resolves the prompt, never exits) drives a regression proven to hang without the race. Nit: the drive-path catch was an empty broad catch that discarded the error (AGENTS.md forbids). Because cancellation is now handled by the race arm, a rejection reaching the catch is always a genuine child-level error — bind it, flatten to error, and surface the original via a new AcpRunSpec.onError sink that the provider wires to ctx.logger.warn, so a real fault is preserved. --- packages/subagent/subagent-acp/src/index.ts | 9 ++- packages/subagent/subagent-acp/src/run.ts | 55 +++++++++++++--- .../subagent-acp/tests/mock-acp-server.ts | 14 ++++ .../subagent-acp/tests/subagent-acp.spec.ts | 65 ++++++++++++++++++- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 66f254b831..037d32889e 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -71,7 +71,7 @@ export const Config: z = z.object({ class AcpProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } - constructor(readonly name: string, private readonly config: Config) {} + constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {} start(request: SubagentStartRequest) { const spec: AcpRunSpec = { @@ -80,11 +80,16 @@ class AcpProvider implements SubagentProvider { cwd: this.config.cwd ?? process.cwd(), permission: this.config.permission, env: this.config.env, + onError: (error, stopReason) => { + // The seam forbids `result` rejecting, so a child-level failure is + // flattened to a stop reason — preserve it here rather than losing it. + this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`) + }, } return startAcpRun(request, spec) } } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new AcpProvider(config.providerName, config)) + ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config)) } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 8aeb136f2d..06f7a9ece8 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -83,6 +83,14 @@ export interface AcpRunSpec { * a test injects a small value to exercise the escalation without a long wait. */ disposeGraceMs?: number + /** + * Sink for a child-level failure that the run flattened into a stop reason + * (the seam contract forbids `result` rejecting). The driver calls this with + * the original error and the chosen stop reason so the fault is preserved + * rather than silently lost; the provider wires it to `ctx.logger.warn`. + * Optional — omitted in a unit test that asserts the stop reason directly. + */ + onError?: (error: Error, stopReason: SubagentStopReason) => void } /** @@ -160,6 +168,15 @@ export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] { return blocks } +/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ +function toError(value: unknown): Error { + // The catch only sees rejections from the ACP SDK RPCs and the spawn `error` + // event, which are always `Error`s; the `String(value)` arm is a defensive + // fallback for a non-Error throw that the typed surfaces cannot produce. + /* v8 ignore next */ + return value instanceof Error ? value : new Error(String(value)) +} + /** Resolve once the child process exits (any code/signal); immediate if gone. */ function waitForExit(child: ChildProcess): Promise { // Already-exited fast path: dispose guards on exitCode before calling, so in @@ -264,8 +281,19 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su ) let sessionId: string | undefined + // Resolves when a cancel is requested, so `result` can settle `aborted` even + // if the child never cooperates with `session/cancel` (it ignores the notify, + // or the prompt wedges). The result path races this against the ACP drive: the + // FIRST to settle wins, so `cancel()` always honors the contract (`result` + // settles `aborted`) without waiting on a non-cooperative child. `dispose` + // still kills the process and reaps it; this only unblocks `result`. The + // executor runs synchronously, so `signalCancelSettled` is assigned before the + // Promise constructor returns (the `!` asserts the definite assignment). + let signalCancelSettled!: () => void + const cancelSettled = new Promise((resolve) => { signalCancelSettled = resolve }) const requestCancel = (): void => { flags.cancelled = true + signalCancelSettled() // Best-effort: tell the child to cancel the in-flight turn. Swallows a // rejection — the session may not exist yet, or the pipe may be gone; the // dispose path kills the process regardless. If the session has NOT been @@ -289,9 +317,14 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return text.length > 0 ? [{ type: 'text', text }] : [] } try { - // Race the ACP drive against a spawn failure: a bad command never speaks - // ACP, so `initialize` would hang forever — the spawn `error` event is the - // only signal, and a rejected race settles the run `error` via the catch. + // Race three outcomes, first to settle wins: + // - driveAcp: the normal initialize → newSession → prompt path; + // - spawnFailed: a bad command never speaks ACP, so `initialize` would + // hang forever — the spawn `error` event is the only signal, and a + // rejected race settles the run `error` via the catch; + // - cancelSettled: a cancel was requested — settle `aborted` immediately + // rather than waiting on a child that may ignore `session/cancel` or + // wedge the prompt (the `cancel()` contract: `result` settles `aborted`). const driveAcp = async (): Promise => { await conn.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -312,13 +345,19 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return await Promise.race([ driveAcp(), spawnFailed.then((err): SubagentResult => { throw err }), + cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) - } catch { + } catch (error: unknown) { // The seam contract: result resolves (never rejects) on a child-level - // failure. A spawn/transport/RPC error becomes an error/aborted result — - // `aborted` if a cancel was requested (the failure is the cancellation - // surfacing as a torn pipe / rejected RPC), else a genuine `error`. - return { output: collectOutput(), stopReason: flags.cancelled ? 'aborted' : 'error' } + // failure. Cancellation is handled by the `cancelSettled` race arm above + // (it settles `aborted` the instant cancel is requested, beating any + // rejection), so a rejection that reaches HERE is always a genuine + // child-level error — the awaited ACP RPCs or the spawn-failure race + // (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a + // local bug. Flatten to `error` and surface the original via onError so a + // real fault is preserved rather than silently lost. + spec.onError?.(toError(error), 'error') + return { output: collectOutput(), stopReason: 'error' } } })() diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 74ae340bde..9cfeac1f44 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -8,6 +8,11 @@ * (`end_turn` default, or `max_tokens`/`refusal`/…). * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for * a `session/cancel`), to exercise the client's cancel path. + * - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives + * `session/cancel` but NEVER resolves the pending prompt + * and never exits — a non-cooperative child. The backend's + * `result` must still settle `aborted` on its own and + * `dispose()` must still kill the process. * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` * before answering, to exercise the client's auto-answer. * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` @@ -63,6 +68,7 @@ const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1' const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1' const THOUGHT = process.env.MOCK_THOUGHT === '1' const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1' +const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1' const READY_FILE = process.env.MOCK_READY_FILE const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF // When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks @@ -150,6 +156,14 @@ function makeAgent(conn: AgentSideConnection): Agent { // path: a transport failure after a cancel settles `aborted`). process.exit(1) } + if (IGNORE_CANCEL) { + // A NON-COOPERATIVE child: receive session/cancel but never resolve the + // pending prompt and never exit. The backend's `result` must still settle + // `aborted` on its own (the cancel-settle race), and `dispose()` must + // still kill the process — proving cancellation does not depend on the + // child cooperating. + return Promise.resolve() + } resolveCancel?.('cancelled') return Promise.resolve() }, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 926319ad87..9819320ec5 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -385,6 +385,20 @@ describe('dsh-subagent-acp', () => { }) it('resolves error (not reject) when the spawn command does not exist', async () => { + // Direct startAcpRun with NO onError sink — the catch must still flatten the + // spawn failure to `error` (the onError call is optional, covering the + // absent-sink branch). + const run = startAcpRun( + { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {} }, + ) + const result = await run.result + // The seam contract: a child-level failure resolves error, never rejects. + expect(result.stopReason).toBe('error') + await run.dispose() + }) + + it('resolves error via the provider (real load path) when the command does not exist', async () => { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(acp, { @@ -396,11 +410,35 @@ describe('dsh-subagent-acp', () => { }) const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) const result = await run.result - // The seam contract: a child-level failure resolves error, never rejects. expect(result.stopReason).toBe('error') await run.dispose() }) + it('reports a flattened child failure through onError (preserved, not silently lost)', async () => { + // The seam forbids `result` rejecting, so a child-level failure is flattened + // to a stop reason — onError must still surface the original error so a real + // fault is logged, not swallowed. A nonexistent command triggers the spawn + // failure path; the spy records the error + the chosen stop reason. + const errors: { message: string; stopReason: string }[] = [] + const run = startAcpRun( + { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, + { + command: '/nonexistent/acp-agent-binary', + args: [], + cwd: process.cwd(), + permission: 'reject', + env: {}, + onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, + }, + ) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(errors).toHaveLength(1) + expect(errors[0]!.stopReason).toBe('error') + expect(errors[0]!.message.length).toBeGreaterThan(0) + await run.dispose() + }) + it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => { // The child hangs, we cancel, and instead of answering the child exits hard // — the pending prompt RPC rejects. With a cancel already requested, the @@ -421,6 +459,31 @@ describe('dsh-subagent-acp', () => { } }) + it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => { + // The contract: run.cancel() → result settles `aborted`. A child that hangs + // its prompt AND ignores session/cancel must not wedge the parent — the + // backend's own cancel-settle path resolves `aborted` without the child's + // cooperation, and dispose() still reaps the process. + const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-')) + const ready = join(tmp, 'ready') + try { + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) + run.cancel('test') + // Bound it: a regression (cancel only notifies the child, which ignores it) + // would hang result forever — fail loud instead of stalling the suite. + const result = await Promise.race([ + run.result, + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }), + ]) + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('advertises no start-time capabilities (out-of-process child)', async () => { const ctx = await setup() const provider = ctx.subagents.getProvider('acp')! From 34f6f28716eb307cabc23a9e8bc0d8b0a94be9c7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:00:59 +0800 Subject: [PATCH 38/40] Make fork reachable by the model in the acp-agent demo (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acp-agent cordis configs loaded the fork backend but bound only one dsh-tool-subagent (to spawn), so the comment's claim that a multi-child scenario could exercise both transports was false — fork was loaded but unreachable by the model. Register a second dsh-tool-subagent bound to fork with a distinct toolName (subagent_fork), matching the coding-agent demo, in both cordis.yml (record/demo) and cordis.snapshot.yml (replay). Snapshot goldens are unchanged (the transcript does not capture the available-tool list). --- examples/acp-agent/cordis.snapshot.yml | 13 +++++++++++-- examples/acp-agent/cordis.yml | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index 5bee10f2a7..5f36a11efa 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -42,8 +42,10 @@ a fresh child agent (it works in its own context and returns only its final result) — give it a complete, standalone instruction. -# The subagent seam + both in-process backends + the model-facing `subagent` -# tool — identical to cordis.yml's wiring (only the LLM backend differs above). +# The subagent seam + both in-process backends + two model-facing tools — +# identical to cordis.yml's wiring (only the LLM backend differs above): spawn +# and fork are each reachable via a dsh-tool-subagent bound to it with a distinct +# toolName (subagent → spawn, subagent_fork → fork). - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -61,3 +63,10 @@ name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index e00e868dce..a00d0e6036 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -51,10 +51,12 @@ a fresh child agent (it works in its own context and returns only its final result) — give it a complete, standalone instruction. -# The subagent seam + both in-process backends + the model-facing `subagent` -# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The -# tool is bound to the `spawn` backend (a fresh child); the `fork` backend is -# loaded too so a multi-child scenario can exercise both transports. +# The subagent seam + both in-process backends + two model-facing tools, as leaf +# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh +# child) and fork (a child seeded with the parent's completed-turn prefix) are +# both reachable by the model: dsh-tool-subagent is loaded once per backend with +# a distinct toolName (subagent → spawn, subagent_fork → fork), so a multi-child +# scenario can exercise both transports. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -72,3 +74,10 @@ name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork From b3d40d427e29c6536ba75328eb74a53045859fad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:55:32 +0800 Subject: [PATCH 39/40] Persist the seed boundary so fork-child replay routes correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fork subagent seeds its child session with a prefix of the parent's log, and that seed becomes the child's persisted log — so a fork child's .jsonl begins with the PARENT's events, including the parent's assistant/chunk events. The snapshot replay harness derived a child's script from its whole log, which would replay the parent's recorded responses as the child's model calls. Spawn-only scenarios never hit it, but a fork snapshot would mis-route silently. Record the seed boundary and skip the inherited prefix at replay: - SessionHeader gains an optional `seedLength` (how many leading events were inherited via a seed), threaded through CreateSessionOptions/CreateAgentOptions meta and stamped by the fork backend (= seeded-prefix length; absent for spawn). It is EXPLICIT, never inferred from seed.length: a resume seeds the whole stored log, so the resume path passes the persisted boundary back. - Both persistence backends round-trip it: JSONL header line, SQLite seed_length column. The SQLite table change bumps SCHEMA_VERSION 2->3; per the pre-release stance the backend rejects an older user_version on open with NO migration. - llm-replay's parseSessionHeader reads seedLength and loadSessionScripts derives a child script from events AFTER the boundary. seedLength is 0 for spawn, so spawn replay is byte-for-byte unchanged. Closes the routing-correctness gap the per-session snapshot replay RFC under- stated; a recorded fork scenario remains a future addition but now derives correctly. RFC: docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md. Regression coverage: a fork child fixture whose seeded prefix carries a parent chunk (derived script must exclude it, proven red without the slice); a seedLength persistence round-trip through the shared coordinator contract (both backends); the fork backend stamping it; resume preserving it from the persisted header. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/persistence.md | 24 ++++++++-- docs/rfc/README.md | 1 + ...6-06-22-fork-child-replay-seed-boundary.md | 47 +++++++++++++++++++ .../2026-06-22-subagent-snapshot-replay.md | 2 +- packages/core/agent-loop/src/index.ts | 3 ++ packages/core/agent-loop/tests/resume.spec.ts | 19 +++++--- packages/core/agent/src/index.ts | 7 +-- packages/core/session/src/index.ts | 1 + packages/core/session/src/types.ts | 22 +++++++-- .../session-persistence-jsonl/src/format.ts | 3 ++ .../session-persistence-sqlite/src/index.ts | 8 ++-- .../session-persistence-sqlite/src/schema.ts | 11 +++-- .../tests/sqlite.spec.ts | 2 +- .../tests/coordinator-contract.ts | 20 ++++++++ .../subagent-fork/tests/subagent-fork.spec.ts | 4 ++ .../subagent/subagent-inprocess/src/index.ts | 3 ++ packages/support/llm-replay/src/index.ts | 26 ++++++---- .../llm-replay/tests/llm-replay.spec.ts | 44 ++++++++++++++--- 19 files changed, 209 insertions(+), 40 deletions(-) create mode 100644 docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index e0fe1cd6c2..f424b8af09 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -332,7 +332,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:116`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/index.ts) ### `ctx.bash` — `BashExecutor` (abstract seam) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 45c1d8dd6b..8d8f032514 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,12 +34,22 @@ interface SessionHeader { cwd?: string /** The session this one was forked from (seed lineage), if any. */ parentSession?: SessionId + /** + * How many leading events were INHERITED via a seed rather than produced by + * this session — the seed boundary. Set when a fork seeds a child with a + * prefix of the parent's log (= the seeded prefix length); absent/0 means the + * session produced all its own events. Persisted so a reload reconstructs the + * boundary instead of re-deriving it from the full stored log, and so a replay + * harness can skip the inherited prefix when deriving the child's OWN script + * (the seeded events are the parent's, not this child's model calls). + */ + seedLength?: number } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv interface CreateSessionOptions { @@ -48,10 +58,16 @@ interface CreateSessionOptions { /** * Creation metadata. The store fills in `version`/`id` and defaults * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, and — when reconstructing a - * persisted session — the original `createdAt` to preserve it). + * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and + * — when reconstructing a persisted session — the original `createdAt` to + * preserve it). + * + * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction + * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full + * length, not the original boundary — the caller must pass the persisted + * boundary back. A fresh fork passes its actual seeded-prefix length. */ - meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } + meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 23e5d60526..22f16ba2dd 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -142,6 +142,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | | [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | | [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | +| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | ## Rejected diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md new file mode 100644 index 0000000000..66f9ff6f52 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -0,0 +1,47 @@ +# RFC: Persist the seed boundary so fork-child replay routes correctly + +Status: implemented + +## Problem + +The [per-session snapshot replay RFC](2026-06-22-subagent-snapshot-replay.md) made the snapshot tier express a nested-agent shape: a parent plus one recorded log per in-process subagent, each replayed as its own script keyed by calling session. It noted (§ Scope, final bullet) that a fork snapshot was "a trivial future addition, not a gap in the keying." That was wrong about a fork child specifically — not the keying, but the *script derivation*. + +A subagent script is derived from a recorded session log by [`deriveReplayScript`](../../../../packages/support/llm-replay): it groups the log's `assistant/chunk` events by `(turn, step)` into one replay entry per `stream()` call. This is correct for a **spawn** child, whose log contains only its own model calls. + +A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). So a fork child's `.jsonl` begins with the **parent's** events — including the parent's `assistant/chunk` events — and only then carries the child's own turn. + +Deriving the child script from the whole fork-child log therefore replays the **parent's** recorded responses as the **child's** model calls: the live fork child's first `stream()` would receive the parent's first recorded chunk sequence instead of its own. The recorded scenarios are all spawn today, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch. + +## Decision + +Record where a session's **inherited** prefix ends, persist it, and have the replay harness derive a child's script from its **own** events only. + +### 1. `seedLength` on the session header + +`SessionHeader` gains an optional `seedLength: number` — how many leading events were inherited via a seed rather than produced by this session. The fork backend stamps it (= the seeded-prefix length) when it creates the child; a fresh spawn leaves it absent (≡ 0). It is threaded through `CreateSessionOptions.meta` (and `CreateAgentOptions.meta`), set in `SessionStore.prepare`. + +`seedLength` is **explicit**, never inferred from `seed.length`. A reconstruction (resume/load) seeds the session with its WHOLE stored log, so `seed.length` there is the full length, not the original boundary — the resume path passes the persisted `seedLength` back from the loaded header instead. (Same shape as `createdAt`, which is also explicitly preserved on reconstruction rather than re-defaulted to now.) + +### 2. Both persistence backends round-trip it + +- **JSONL**: a `seedLength` field on the header line (`toHeaderLine`/`fromHeaderLine`). +- **SQLite**: a `seed_length` column on the `sessions` table. + +The SQLite change is a breaking table-layout change, so `SCHEMA_VERSION` bumps **2 → 3**. Per the repo's pre-release stance (§ "Pre-release stance" in AGENTS.md) the backend **rejects** a non-current `user_version` on open rather than migrating it — there is no persisted user data to preserve, so no migration code is written (the existing reject-not-migrate path at `openDatabase` already enforces this; v1 and now v2 are both rejected). + +### 3. Replay derives a child script after the boundary + +`dsh-llm-replay`'s `parseSessionHeader` now also reads `seedLength` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(seedLength)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child `seedLength` is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged. + +This closes the routing correctness gap; an actual fork *scenario* (a recorded `subagent-multi`-style fixture with a fork child) is still a future addition, but it can now be recorded and replayed correctly rather than mis-routing. + +## Alternatives considered + +- **Derive the boundary heuristically in `llm-replay`** (the seeded prefix is contiguous parent events ending at the last `turn/end` before the child's first `user/message`). Rejected: a brittle heuristic in the test harness that re-derives a fact the producer already knows. Persisting the boundary at its source (the fork backend) is the "explicit > implicit at package seams" rule applied across the persistence boundary — the reader of a child fixture never has to reconstruct where the inheritance ended. +- **Pin the format version instead of bumping** (the `SESSION_FORMAT_VERSION = 0` "unstable" stance the event log uses). Rejected for the SQLite *table* layout: `SCHEMA_VERSION` is the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabulary `version`. Adding a column is precisely the breaking table change it versions, so it bumps. + +## Consequences + +- A new persisted header field across core + both backends; the core-data-structures catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks). +- Existing SQLite databases at schema v2 are rejected on open (no user data pre-release). +- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a persistence round-trip test (both backends, via the shared coordinator contract). diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index acb29e401d..2aece77949 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -50,5 +50,5 @@ Both replay keyless in the default gate. - The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape. - `GenerateOptions.sessionId` is a small, honest core-seam addition useful beyond replay (telemetry, request routing). -- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The fork backend is loaded in the example and exercised by PR2's unit tests; a mixed spawn+fork snapshot would need a second tool instance bound to `fork` (pure config) and is a trivial future addition, not a gap in the keying — the keying routes by session, not by backend. +- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md). A recorded mixed spawn+fork *scenario* (a second tool instance bound to `fork`, pure config) remains a future addition, but a fork child now derives correctly. - Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in the PR3 plan. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index e5393ed0aa..ab95ea5aac 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -219,6 +219,9 @@ export class AgentLoop extends Service implements AgentFactory { createdAt: meta.createdAt, ...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, + // Reconstruct the seed boundary from the persisted header, NOT from + // `events.length` (the resume seeds the WHOLE stored log). + ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, }, }) return this.startOwned(options.agentId, options.agentOptions ?? {}, session) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 6192396cab..074e84d78a 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -94,9 +94,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.fiber.dispose() }) - it('resume of a forked session preserves the parentSession lineage in the header', async () => { - // Lifecycle 1: persist a FORKED session (carries parentSession in its - // header) by creating it with a complete-turn seed — the write path + it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => { + // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength + // in its header) by creating it with a complete-turn seed — the write path // materializes the fork (header + seed) on disk. const seed: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -104,12 +104,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ] const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } }) + const forked = ctx1.sessions.create(SessionId('forked-sess'), { + seed, + meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length }, + }) await ctx1.parallel('session/flush', forked) await ctx1.fiber.dispose() - // Lifecycle 2: resume it; the parentSession header survives the round-trip - // (exercises resume's parentSession-present branch). + // Lifecycle 2: resume it; the parentSession + seedLength header survives the + // round-trip (exercises resume's parentSession- and seedLength-present + // branches). seedLength must come from the PERSISTED header, not from the + // resume seed length (which is the whole stored log, not the original + // boundary). const adapter2 = new MockAdapter([textResponse('b')]) const ctx2 = new Context() await ctx2.plugin(LlmService) @@ -123,6 +129,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') + expect(a2.session.header.seedLength).toBe(seed.length) await ctx2.fiber.dispose() }) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 1f2984a148..940a5c9db1 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -30,13 +30,14 @@ export interface CreateAgentOptions { /** The live session's id (NOT derived from agentId). */ sessionId: SessionId /** - * Session creation metadata: validated absolute `cwd` and `parentSession` - * fork lineage. Mirrors the `cwd`/`parentSession` fields of + * Session creation metadata: validated absolute `cwd`, `parentSession` + * fork lineage, and the `seedLength` seed boundary. Mirrors the + * `cwd`/`parentSession`/`seedLength` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). */ - meta?: { cwd?: string; parentSession?: SessionId } + meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number } /** * Seed events to reconstruct the child session's log from (the fork lineage * primitive). When present, the factory creates the session with this event diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cef91c110c..b547a8dba9 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -289,6 +289,7 @@ export class SessionStore extends Service { createdAt: options?.meta?.createdAt ?? Date.now(), ...cwd !== undefined ? { cwd } : {}, ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, + ...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {}, } return new Session(sessionId, options?.seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 2302b5b94d..13bcd2a8c6 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -50,6 +50,16 @@ export interface SessionHeader { cwd?: string /** The session this one was forked from (seed lineage), if any. */ parentSession?: SessionId + /** + * How many leading events were INHERITED via a seed rather than produced by + * this session — the seed boundary. Set when a fork seeds a child with a + * prefix of the parent's log (= the seeded prefix length); absent/0 means the + * session produced all its own events. Persisted so a reload reconstructs the + * boundary instead of re-deriving it from the full stored log, and so a replay + * harness can skip the inherited prefix when deriving the child's OWN script + * (the seeded events are the parent's, not this child's model calls). + */ + seedLength?: number } /** @@ -63,10 +73,16 @@ export interface CreateSessionOptions { /** * Creation metadata. The store fills in `version`/`id` and defaults * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, and — when reconstructing a - * persisted session — the original `createdAt` to preserve it). + * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and + * — when reconstructing a persisted session — the original `createdAt` to + * preserve it). + * + * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction + * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full + * length, not the original boundary — the caller must pass the persisted + * boundary back. A fresh fork passes its actual seeded-prefix length. */ - meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } + meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 32258c6a37..63cf899e45 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -24,6 +24,7 @@ export interface HeaderLine { createdAt: number cwd?: string parentSession?: SessionId + seedLength?: number } /** Build the header line object from a {@link SessionHeader}. */ @@ -35,6 +36,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { createdAt: header.createdAt, ...header.cwd !== undefined ? { cwd: header.cwd } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, + ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, } } @@ -46,6 +48,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { createdAt: line.createdAt, ...line.cwd !== undefined ? { cwd: line.cwd } : {}, ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, + ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index cef61cb071..e62fcce592 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -224,19 +224,21 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session) - VALUES (?, ?, ?, ?, ?) + INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, - parent_session = excluded.parent_session + parent_session = excluded.parent_session, + seed_length = excluded.seed_length `).run( meta.id, meta.version, meta.createdAt, meta.cwd ?? null, meta.parentSession ?? null, + meta.seedLength ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 8238cba30c..04ad77e9e2 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-se * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 2 +export const SCHEMA_VERSION = 3 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -30,6 +30,7 @@ export interface SessionRow { created_at: number cwd: string | null parent_session: string | null + seed_length: number | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -51,8 +52,8 @@ export interface EventRow { * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the * current one (written by a different, incompatible build — older or newer) is * REJECTED rather than opened against a layout this build does not understand. - * There are no migrations: v1 had a different `sessions` layout and is not - * upgraded in place. + * There are no migrations: an earlier layout (v1's different `sessions` shape, + * v2 without the `seed_length` column) is not upgraded in place — it is rejected. */ export function openDatabase(path: string): DatabaseSync { const db = new DatabaseSync(path) @@ -76,7 +77,8 @@ export function openDatabase(path: string): DatabaseSync { version INTEGER NOT NULL, created_at INTEGER NOT NULL, cwd TEXT, - parent_session TEXT + parent_session TEXT, + seed_length INTEGER ) STRICT `) db.exec(` @@ -100,6 +102,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { createdAt: row.created_at, ...row.cwd !== null ? { cwd: row.cwd } : {}, ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, + ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 2bc9c59643..f3ac840f67 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -315,7 +315,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(2) + expect(SCHEMA_VERSION).toBe(3) }) }) diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 431d02b4cb..00f87dbae3 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -123,6 +123,26 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('round-trips the seed boundary (seedLength) through persistence', async () => { + // A forked child records how many leading events were inherited via the + // seed; the boundary must survive a reload (so a resume/replay can tell the + // inherited prefix from the child's own events). Both backends carry it on + // the header — JSONL on the header line, SQLite in the seed_length column. + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) + send(session, oneTurnLog()) + await ctx.parallel('session/flush', session) + + const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) + expect(loaded.meta.seedLength).toBe(3) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 96cdd55141..56441a656f 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -106,6 +106,10 @@ describe('dsh-subagent-fork', () => { expect(seededUser).toBeDefined() // Lineage stamped. expect(child.session.header.parentSession).toBe(parent.session.header.id) + // The seed boundary is recorded on the header (= the seeded prefix length), + // so a reload / replay harness can tell the inherited prefix from the + // child's own events. + expect(child.session.header.seedLength).toBe(parentPrefixLen) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index d2840881af..4b8d2d4c99 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -122,6 +122,9 @@ export function startInProcessRun( meta: { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, + // Record the seed boundary so a reload (and a replay harness) can tell the + // inherited prefix from the child's OWN events. 0 for a fresh spawn. + ...seedLength > 0 ? { seedLength } : {}, }, ...options.seed !== undefined ? { seed: options.seed } : {}, agentOptions, diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index b804c8ebd9..78f46e705e 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -140,18 +140,21 @@ export function parseSessionLog(text: string): SessionEvent[] { /** * Read the identifying facts off a session log's header line (line 0): the - * recorded session `id` (diagnostics) and `createdAt` (the deterministic - * ordering key that binds a recorded script to a live session — see - * {@link SessionScript}). A header missing either field falls back to a stable - * default (`''` / `0`) rather than throwing: a no-model fixture is header-only - * and still orders fine as the single (primary) script. + * recorded session `id` (diagnostics), `createdAt` (the deterministic ordering + * key that binds a recorded script to a live session — see + * {@link SessionScript}), and `seedLength` (the seed boundary — how many leading + * events were INHERITED via a fork seed rather than produced by this session's + * own model calls; absent ⇒ 0). A header missing a field falls back to a stable + * default (`''` / `0` / `0`) rather than throwing: a no-model fixture is + * header-only and still orders fine as the single (primary) script. */ -export function parseSessionHeader(text: string): { id: string; createdAt: number } { +export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } { const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}' - const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown } + const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; seedLength?: unknown } return { id: typeof parsed.id === 'string' ? parsed.id : '', createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0, + seedLength: typeof parsed.seedLength === 'number' ? parsed.seedLength : 0, } } @@ -257,10 +260,17 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { } const text = readFileSync(childFile, 'utf8') const header = parseSessionHeader(text) + // Derive the child's script from its OWN events only — events AT OR AFTER + // the seed boundary. A FORK child's log begins with the seeded parent prefix + // (the parent's events, including its `assistant/chunk`s); replaying those as + // the child's model calls would feed the child the PARENT's recorded + // responses. `seedLength` is 0 for a fresh (spawn) child, so this is a no-op + // there. + const ownEvents = parseSessionLog(text).slice(header.seedLength) children.push({ recordedId: header.id, createdAt: header.createdAt, - entries: deriveReplayScript(parseSessionLog(text)), + entries: deriveReplayScript(ownEvents), primary: false, }) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index a0c6268eff..2dd8357cc7 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -35,12 +35,13 @@ const TEXT_CHUNKS: StreamChunk[] = [ ] /** Build a minimal session-JSONL string: a header line + the given events. */ -function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number }): string { +function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string { const headerLine = JSON.stringify({ type: 'session', version: 0, id: header?.id ?? 's1', createdAt: header?.createdAt ?? 0, + ...header?.seedLength !== undefined ? { seedLength: header.seedLength } : {}, }) return [headerLine, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' } @@ -370,17 +371,22 @@ describe('installLlmReplay (through the real waterfall)', () => { }) describe('parseSessionHeader', () => { - it('reads id and createdAt off the header line', () => { + it('reads id, createdAt, and seedLength off the header line', () => { expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 }))) - .toEqual({ id: 'abc', createdAt: 42 }) + .toEqual({ id: 'abc', createdAt: 42, seedLength: 0 }) }) - it('falls back to id="" / createdAt=0 when the header lacks them', () => { - expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0 }) + it('reads a non-zero seedLength (a fork child header)', () => { + expect(parseSessionHeader('{"type":"session","version":0,"id":"child","createdAt":7,"seedLength":4}\n')) + .toEqual({ id: 'child', createdAt: 7, seedLength: 4 }) + }) + + it('falls back to id="" / createdAt=0 / seedLength=0 when the header lacks them', () => { + expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, seedLength: 0 }) }) it('falls back on an empty buffer (no header line)', () => { - expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0 }) + expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, seedLength: 0 }) }) }) @@ -420,6 +426,32 @@ describe('loadSessionScripts', () => { .toThrow(/child fixture not found/) }) + it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => { + // A fork child's log begins with the seeded parent prefix — the parent's + // events, INCLUDING its assistant/chunk events. Deriving the child script + // from the whole log would replay the PARENT's recorded responses as the + // child's model calls. With seedLength recorded, the child script must + // contain only the child's OWN chunks (those after the boundary). + const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' } + const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }] + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + // The child fixture: 2 seeded parent events (a chunk + its finish) then the + // child's own turn. seedLength = 2 marks where the inherited prefix ends. + const childEvents: SessionEvent[] = [ + chunkEvent(0, 1, 1, parentChunk), + chunkEvent(1, 1, 1, { type: 'finish', reason: { kind: 'stop' } }), + chunkEvent(2, 2, 1, childChunks[0]!), + chunkEvent(3, 2, 1, childChunks[1]!), + ] + const childPath = join(dir, 'session.1.jsonl') + writeFileSync(childPath, sessionJsonl(childEvents, { id: 'child', createdAt: 200, seedLength: 2 }), 'utf8') + + const scripts = loadSessionScripts({ file: f, childFiles: [childPath] }) + // The child script is ONLY the child's own model call — the parent's seeded + // chunk is gone. + expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: childChunks }]) + }) + it('uses the override for the primary and still derives children', () => { writeFileSync(file, sessionJsonl([], { id: 'p', createdAt: 1 }), 'utf8') const overrideFile = join(dir, 'replay.override.json') From 78d60366ecb2e7faf5c756860869c41bf5ee308d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:20:54 +0800 Subject: [PATCH 40/40] Record fork and mixed spawn+fork snapshot scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed-boundary change made fork-child replay route correctly but shipped with no recorded fork scenario — the seedLength slice was exercised only by llm-replay unit tests and a persistence round-trip, never by the full-transcript snapshot tier. Add two recorded scenarios that drive a real fork child through it: - subagent-fork: parent completes a turn, then forks one child (child fixture carries a non-zero seedLength, the boundary the replay slice consumes). - subagent-mixed: parent completes a turn, then delegates once via spawn (seedLength 0) and once via fork (non-zero seedLength) in one transcript — the first scenario to drive two subagent backends at once, exercising both branches of the slice. Both need a completed turn-1 so the fork seed is a non-empty completed-turn prefix (a turn-1 fork seeds empty = spawn, which would not exercise the slice). Removing the slice turns both scenarios red (the fork child receives the parent's recorded chunks), proving the guard bites. ACP (out-of-process) subagent replay remains a different shape, still tracked as TODO(acp-subagent-replay). --- docs/rfc/README.md | 1 + ...6-06-22-fork-child-replay-seed-boundary.md | 2 +- .../2026-06-22-fork-snapshot-scenarios.md | 27 ++ .../2026-06-22-subagent-snapshot-replay.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 2 + .../tests/snapshots/subagent-fork/input.json | 8 + .../snapshots/subagent-fork/session.1.jsonl | 89 +++++ .../snapshots/subagent-fork/session.jsonl | 190 ++++++++++ .../subagent-fork/stdout.golden.jsonl | 114 ++++++ .../tests/snapshots/subagent-mixed/input.json | 8 + .../snapshots/subagent-mixed/session.1.jsonl | 35 ++ .../snapshots/subagent-mixed/session.2.jsonl | 97 +++++ .../snapshots/subagent-mixed/session.jsonl | 346 ++++++++++++++++++ .../subagent-mixed/stdout.golden.jsonl | 228 ++++++++++++ 14 files changed, 1147 insertions(+), 2 deletions(-) create mode 100644 docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-fork/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-mixed/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 22f16ba2dd..8667cccba2 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -143,6 +143,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | | [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | | [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | +| [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | ## Rejected diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index 66f9ff6f52..a45e62639b 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -33,7 +33,7 @@ The SQLite change is a breaking table-layout change, so `SCHEMA_VERSION` bumps * `dsh-llm-replay`'s `parseSessionHeader` now also reads `seedLength` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(seedLength)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child `seedLength` is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged. -This closes the routing correctness gap; an actual fork *scenario* (a recorded `subagent-multi`-style fixture with a fork child) is still a future addition, but it can now be recorded and replayed correctly rather than mis-routing. +This closes the routing correctness gap, and two recorded fork scenarios exercise it end to end — see [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md). ## Alternatives considered diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md new file mode 100644 index 0000000000..2ad44e2040 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -0,0 +1,27 @@ +# RFC: Record fork and mixed spawn+fork snapshot scenarios + +Status: implemented + +## Problem + +The [seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) made fork-child replay route correctly: `dsh-llm-replay` derives a child's script from the events at or after its persisted `seedLength` boundary, so a fork child's inherited parent prefix is not replayed as the child's own model calls. But it shipped with **no recorded fork scenario** — the slice was exercised only by `llm-replay`'s unit tests (a synthetic child fixture) and a persistence round-trip test. The full-transcript snapshot tier, the one net that boots the real `acp-agent` and replays an end-to-end nested transcript, had only spawn children (`subagent-spawn`, `subagent-multi`). A fork-routing regression that left the unit tests green would still have escaped the tier built to catch transcript regressions. + +The snapshot infrastructure to express a fork scenario was already in place — both in-process backends are wired into `cordis.yml` / `cordis.snapshot.yml` as two model-facing tools (`subagent` → spawn, `subagent_fork` → fork), the harness harvests every child log, and replay forwards per-child fixtures keyed by `seedLength`. What was missing was a *recorded scenario* that drives a fork child through it. + +## Decision + +Record two scenarios against the real API, both replayed keyless in the default gate: + +- **`subagent-fork`** — the parent completes a turn that establishes a fact, then delegates one subtask via `subagent_fork`. The fork child inherits the conversation (its log carries a non-zero `seedLength`), so it can answer from the parent's context. This is the focused regression: the child fixture's `seedLength` is the boundary the replay slice depends on, recorded from a real fork rather than hand-synthesized. +- **`subagent-mixed`** — the parent completes a turn, then delegates once via `subagent` (a fresh spawn child, `seedLength` 0) and once via `subagent_fork` (a fork child, non-zero `seedLength`) in one transcript. This is the mixed spawn+fork scenario the seed-boundary and per-session-replay RFCs both named as a future addition: one transcript exercises both transports and both branches of the slice (`seedLength` 0 = no-op, `seedLength > 0` = trim the inherited prefix), with the two children ordered spawn-then-fork by `createdAt`. + +### Why a completed turn-1 is required + +The fork backend seeds the child with the parent's **balanced completed-turn prefix** ([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork)). A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. + +## Consequences + +- The fork-routing slice is now guarded at the full-transcript tier, not just by unit tests. Removing the `slice(seedLength)` (replaying the whole child log) turns **both** new scenarios red — the fork child receives the parent's recorded chunks instead of its own — proving the guard bites (verified red→green when the scenarios landed). +- `subagent-mixed` is the first snapshot scenario to drive two *different* subagent backends in one transcript, exercising the per-session replay keying across a spawn and a fork child simultaneously. +- Out-of-process (ACP) subagent replay remains a different shape (each child is its own process with its own replay) and is still tracked as `TODO(acp-subagent-replay)` — these scenarios are in-process only. +- Re-recording (`pnpm run test:snapshot:record`) regenerates all four fork/spawn fixtures from the live API; the two new scenarios self-skip without a key like every recorded scenario. diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 2aece77949..e72175e544 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -50,5 +50,5 @@ Both replay keyless in the default gate. - The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape. - `GenerateOptions.sessionId` is a small, honest core-seam addition useful beyond replay (telemetry, request routing). -- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md). A recorded mixed spawn+fork *scenario* (a second tool instance bound to `fork`, pure config) remains a future addition, but a fork child now derives correctly. +- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios now exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)). - Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in the PR3 plan. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index ffdea0f94e..42e9107c04 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -58,6 +58,8 @@ const SCENARIOS: Scenario[] = [ { name: 'cancel', hasModelTurn: true, recorded: false }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, ] /** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */ diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/input.json b/examples/acp-agent/tests/snapshots/subagent-fork/input.json new file mode 100644 index 0000000000..366a97e3b5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools." }, + { "op": "prompt", "text": "Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl new file mode 100644 index 0000000000..778d06b5c9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -0,0 +1,89 @@ +{"type":"session","version":0,"id":"906f1eac-a457-4eb9-828b-1ba537552524","createdAt":1782133845692,"cwd":"/tmp/acp-snap-cwd-Ml0DrO","parentSession":"f2358dc0-75f8-4649-8440-ab94b8e10dc3","seedLength":38} +{"type":"turn/start","seq":0,"time":1782133842298,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133842298,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782133842299,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133843792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133843793,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133843861,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133843888,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":10,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1782133843913,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":12,"time":1782133843940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":13,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":14,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" respond"}}} +{"type":"assistant/chunk","seq":16,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":18,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":20,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":21,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":22,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":23,"time":1782133843990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":25,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":26,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":27,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":28,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1782133844039,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":31,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."}}}} +{"type":"assistant/chunk","seq":32,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":33,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":34,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1782133844042,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}}} +{"type":"step/end","seq":36,"time":1782133844042,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1782133844042,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":38,"time":1782133845693,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":39,"time":1782133845693,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":40,"time":1782133845693,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":41,"time":1782133846927,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":42,"time":1782133846927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":43,"time":1782133847020,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":44,"time":1782133847044,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":45,"time":1782133847069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":46,"time":1782133847069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":47,"time":1782133847069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":48,"time":1782133847094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":50,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":51,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":52,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":53,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":54,"time":1782133847120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":55,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":56,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":57,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":58,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":60,"time":1782133847146,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":61,"time":1782133847146,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" later"}}} +{"type":"assistant/chunk","seq":62,"time":1782133847171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":63,"time":1782133847171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":64,"time":1782133847171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":65,"time":1782133847197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":66,"time":1782133847197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":67,"time":1782133847197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":68,"time":1782133847222,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":69,"time":1782133847223,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":70,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":71,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":72,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":73,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":74,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":75,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":76,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":77,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"M"}}} +{"type":"assistant/chunk","seq":78,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ARM"}}} +{"type":"assistant/chunk","seq":79,"time":1782133847301,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":80,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ADE"}}} +{"type":"assistant/chunk","seq":81,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and then later asked what it was. I should reply with exactly that one word."}}}} +{"type":"assistant/chunk","seq":82,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":83,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1210,"outputTokens":39,"cacheReadTokens":0,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":84,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":85,"time":1782133847303,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and then later asked what it was. I should reply with exactly that one word."},{"type":"text","text":"MARMALADE"}],"usage":{"inputTokens":1210,"outputTokens":39,"cacheReadTokens":0,"reasoningTokens":34}}} +{"type":"step/end","seq":86,"time":1782133847303,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":87,"time":1782133847303,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl new file mode 100644 index 0000000000..25a829130d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -0,0 +1,190 @@ +{"type":"session","version":0,"id":"f2358dc0-75f8-4649-8440-ab94b8e10dc3","createdAt":1782133842294,"cwd":"/tmp/acp-snap-cwd-Ml0DrO"} +{"type":"turn/start","seq":0,"time":1782133842298,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133842298,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782133842299,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133843792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133843793,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133843861,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133843888,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":10,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1782133843913,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":12,"time":1782133843940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":13,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":14,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" respond"}}} +{"type":"assistant/chunk","seq":16,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":18,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":20,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":21,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":22,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":23,"time":1782133843990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":25,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":26,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":27,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":28,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1782133844039,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":31,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."}}}} +{"type":"assistant/chunk","seq":32,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":33,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":34,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1782133844042,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}}} +{"type":"step/end","seq":36,"time":1782133844042,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1782133844042,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":38,"time":1782133844049,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":39,"time":1782133844049,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":40,"time":1782133844049,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":41,"time":1782133844782,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":42,"time":1782133844782,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":43,"time":1782133845002,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":44,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":45,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":46,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":47,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":48,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":49,"time":1782133845029,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":50,"time":1782133845029,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":51,"time":1782133845030,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":52,"time":1782133845030,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":53,"time":1782133845030,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":54,"time":1782133845054,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":55,"time":1782133845054,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":56,"time":1782133845054,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":57,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":58,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":59,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":60,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":61,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":62,"time":1782133845104,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":63,"time":1782133845104,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":64,"time":1782133845104,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":65,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ked"}}} +{"type":"assistant/chunk","seq":66,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":67,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":68,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":69,"time":1782133845130,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":70,"time":1782133845154,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":71,"time":1782133845154,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":72,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" so"}}} +{"type":"assistant/chunk","seq":73,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":74,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":75,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":76,"time":1782133845180,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" able"}}} +{"type":"assistant/chunk","seq":77,"time":1782133845180,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1782133845180,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} +{"type":"assistant/chunk","seq":79,"time":1782133845204,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":80,"time":1782133845205,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":81,"time":1782133845230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mention"}}} +{"type":"assistant/chunk","seq":82,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":83,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":84,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":85,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":86,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":87,"time":1782133845281,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":88,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":89,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":90,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":91,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":92,"time":1782133845308,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":93,"time":1782133845308,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":94,"time":1782133845384,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":95,"time":1782133845385,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":96,"time":1782133845411,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":97,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":99,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":100,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":101,"time":1782133845433,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1782133845434,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"Ret"}}} +{"type":"assistant/chunk","seq":103,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"rieve"}}} +{"type":"assistant/chunk","seq":104,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":105,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":106,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":107,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":108,"time":1782133845484,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":110,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":112,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":113,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":115,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":116,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":117,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":118,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":119,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":120,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":121,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":122,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":123,"time":1782133845563,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":124,"time":1782133845563,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":125,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":126,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":127,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":128,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":129,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":130,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":131,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":132,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":133,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":134,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":135,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":136,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":137,"time":1782133845637,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":138,"time":1782133845637,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":139,"time":1782133845637,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1782133845662,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":141,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to ask a question about the project codeword. The forked child inherits this conversation, so it should be able to see the earlier mention of \"MARMALADE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":142,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","arguments":"{\"description\": \"Retrieve project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":143,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":143,"outputTokens":141,"cacheReadTokens":1280,"reasoningTokens":52}}}} +{"type":"assistant/chunk","seq":144,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":145,"time":1782133845691,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to ask a question about the project codeword. The forked child inherits this conversation, so it should be able to see the earlier mention of \"MARMALADE\". Let me do that."},{"type":"tool-call","id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","arguments":"{\"description\": \"Retrieve project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"usage":{"inputTokens":143,"outputTokens":141,"cacheReadTokens":1280,"reasoningTokens":52}}} +{"type":"tool/call","seq":146,"time":1782133845691,"data":{"turn":2,"step":1,"callId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","arguments":"{\"description\": \"Retrieve project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":147,"time":1782133847305,"data":{"turn":2,"step":1,"callId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","content":[{"type":"text","text":"MARMALADE"}],"isError":false}} +{"type":"step/end","seq":148,"time":1782133847305,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":149,"time":1782133847305,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":150,"time":1782133847941,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":151,"time":1782133847941,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":152,"time":1782133848080,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":153,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":154,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":155,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":156,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":157,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":158,"time":1782133848131,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":159,"time":1782133848131,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":160,"time":1782133848131,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":161,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":162,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":163,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" correct"}}} +{"type":"assistant/chunk","seq":164,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":165,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":166,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":167,"time":1782133848179,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":168,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":169,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":170,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":171,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":172,"time":1782133848205,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":173,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":174,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":175,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":176,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":177,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":178,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":179,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":180,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":181,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":182,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"MARMALADE\", which is correct. Now I need to reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":183,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":184,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":31,"cacheReadTokens":1536,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":185,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":186,"time":1782133848233,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"MARMALADE\", which is correct. Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":44,"outputTokens":31,"cacheReadTokens":1536,"reasoningTokens":26}}} +{"type":"step/end","seq":187,"time":1782133848233,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":188,"time":1782133848233,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl new file mode 100644 index 0000000000..f14353a7ea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -0,0 +1,114 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" respond"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" question"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" so"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" able"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" earlier"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mention"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Retrieve project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correct"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/input.json b/examples/acp-agent/tests/snapshots/subagent-mixed/input.json new file mode 100644 index 0000000000..38cad9c585 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools." }, + { "op": "prompt", "text": "Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl new file mode 100644 index 0000000000..c48530eeb0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"be77a907-c832-4870-8e71-beb6e57d3726","createdAt":1782133872837,"cwd":"/tmp/acp-snap-cwd-J8rqO2","parentSession":"6d80d699-1744-467a-80a3-e3c73110adda"} +{"type":"turn/start","seq":0,"time":1782133872838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133872838,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782133872838,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133874026,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133874026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133874140,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782133874167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":16,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":17,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":18,"time":1782133874215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":25,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":26,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":27,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":28,"time":1782133874242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":29,"time":1782133874242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":778,"outputTokens":23,"cacheReadTokens":384,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":30,"time":1782133874242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1782133874242,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"usage":{"inputTokens":778,"outputTokens":23,"cacheReadTokens":384,"reasoningTokens":19}}} +{"type":"step/end","seq":32,"time":1782133874242,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1782133874242,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl new file mode 100644 index 0000000000..42fb694c38 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -0,0 +1,97 @@ +{"type":"session","version":0,"id":"3659a741-05d5-4382-93e5-977b8d563ab3","createdAt":1782133875844,"cwd":"/tmp/acp-snap-cwd-J8rqO2","parentSession":"6d80d699-1744-467a-80a3-e3c73110adda","seedLength":44} +{"type":"turn/start","seq":0,"time":1782133869872,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133869873,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782133869873,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133870753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":7,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":8,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1782133870802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1782133870827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":17,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":18,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":19,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":22,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":24,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":25,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":27,"time":1782133870904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":28,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":29,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":30,"time":1782133870954,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":31,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":32,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":33,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":34,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":35,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":36,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":37,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."}}}} +{"type":"assistant/chunk","seq":38,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":39,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":40,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":41,"time":1782133870983,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}}} +{"type":"step/end","seq":42,"time":1782133870983,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":43,"time":1782133870983,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":44,"time":1782133875845,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":45,"time":1782133875845,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":46,"time":1782133875845,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":47,"time":1782133876624,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":48,"time":1782133876624,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":49,"time":1782133876870,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":50,"time":1782133876896,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":51,"time":1782133876922,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":52,"time":1782133876923,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":53,"time":1782133876923,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":54,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":55,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":56,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":57,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":58,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":59,"time":1782133876947,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":60,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":61,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":62,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":63,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":65,"time":1782133876995,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" later"}}} +{"type":"assistant/chunk","seq":66,"time":1782133876996,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":67,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":68,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":69,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":70,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} +{"type":"assistant/chunk","seq":71,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":72,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":73,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":74,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":75,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":76,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":77,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":78,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":81,"time":1782133877098,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":82,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":83,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":84,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":86,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SA"}}} +{"type":"assistant/chunk","seq":87,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FF"}}} +{"type":"assistant/chunk","seq":88,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"RON"}}} +{"type":"assistant/chunk","seq":89,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"SAFFRON\" for later, and now they're asking what it is. I should reply with just that one word."}}}} +{"type":"assistant/chunk","seq":90,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":91,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":57,"outputTokens":41,"cacheReadTokens":1152,"reasoningTokens":37}}}} +{"type":"assistant/chunk","seq":92,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":93,"time":1782133877126,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"SAFFRON\" for later, and now they're asking what it is. I should reply with just that one word."},{"type":"text","text":"SAFFRON"}],"usage":{"inputTokens":57,"outputTokens":41,"cacheReadTokens":1152,"reasoningTokens":37}}} +{"type":"step/end","seq":94,"time":1782133877126,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":95,"time":1782133877126,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl new file mode 100644 index 0000000000..c4bc990440 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -0,0 +1,346 @@ +{"type":"session","version":0,"id":"6d80d699-1744-467a-80a3-e3c73110adda","createdAt":1782133869868,"cwd":"/tmp/acp-snap-cwd-J8rqO2"} +{"type":"turn/start","seq":0,"time":1782133869872,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133869873,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":1782133869873,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133870753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":7,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":8,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1782133870802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1782133870827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":17,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":18,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":19,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":22,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":24,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":25,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":27,"time":1782133870904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":28,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":29,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":30,"time":1782133870954,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":31,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":32,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":33,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":34,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":35,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":36,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":37,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."}}}} +{"type":"assistant/chunk","seq":38,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":39,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":40,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":41,"time":1782133870983,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}}} +{"type":"step/end","seq":42,"time":1782133870983,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":43,"time":1782133870983,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":44,"time":1782133870991,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":45,"time":1782133870991,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":46,"time":1782133870991,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":47,"time":1782133871816,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":48,"time":1782133871816,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":49,"time":1782133871942,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":50,"time":1782133871966,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":51,"time":1782133871967,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":52,"time":1782133871967,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":53,"time":1782133871967,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":54,"time":1782133871994,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":55,"time":1782133871994,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" deleg"}}} +{"type":"assistant/chunk","seq":56,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ations"}}} +{"type":"assistant/chunk","seq":57,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} +{"type":"assistant/chunk","seq":58,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":59,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":60,"time":1782133872019,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":61,"time":1782133872019,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":62,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":63,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":64,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":65,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":66,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":67,"time":1782133872044,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":68,"time":1782133872068,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":69,"time":1782133872069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":70,"time":1782133872093,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":71,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":72,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":73,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":74,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":75,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":76,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":77,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" AL"}}} +{"type":"assistant/chunk","seq":78,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":79,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":80,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":81,"time":1782133872121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":82,"time":1782133872144,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":83,"time":1782133872171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":84,"time":1782133872172,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":85,"time":1782133872172,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":86,"time":1782133872172,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":87,"time":1782133872197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":88,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":89,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":90,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":91,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":92,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":93,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":94,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":95,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":96,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":97,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":98,"time":1782133872225,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":99,"time":1782133872247,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":100,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":101,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"What"}}} +{"type":"assistant/chunk","seq":102,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":103,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":104,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":105,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":106,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":107,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":108,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} +{"type":"assistant/chunk","seq":109,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":110,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":111,"time":1782133872297,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":112,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":113,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"?"}}} +{"type":"assistant/chunk","seq":114,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} +{"type":"assistant/chunk","seq":115,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":116,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":117,"time":1782133872324,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":118,"time":1782133872324,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":119,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":120,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":121,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":122,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":123,"time":1782133872348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":124,"time":1782133872349,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":125,"time":1782133872349,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":126,"time":1782133872349,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":127,"time":1782133872374,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":128,"time":1782133872374,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":129,"time":1782133872374,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":130,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":131,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":132,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":133,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":134,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":135,"time":1782133872403,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":136,"time":1782133872426,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":137,"time":1782133872426,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'.\n\n"}}} +{"type":"assistant/chunk","seq":138,"time":1782133872427,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":139,"time":1782133872427,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":140,"time":1782133872427,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":141,"time":1782133872452,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":142,"time":1782133872453,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":143,"time":1782133872453,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":144,"time":1782133872453,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":145,"time":1782133872478,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":146,"time":1782133872528,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":147,"time":1782133872528,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":148,"time":1782133872553,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":149,"time":1782133872554,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":150,"time":1782133872554,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":151,"time":1782133872604,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":152,"time":1782133872605,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":153,"time":1782133872605,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1782133872605,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":155,"time":1782133872624,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":156,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":157,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":158,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":159,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":160,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":161,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":162,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":163,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":164,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":165,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":166,"time":1782133872708,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":167,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":168,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":169,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":170,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":172,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":173,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":174,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":175,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":176,"time":1782133872735,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":177,"time":1782133872766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":178,"time":1782133872767,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":179,"time":1782133872767,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":180,"time":1782133872800,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":181,"time":1782133872835,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool with the prompt 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool with the prompt 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with the first one."}}}} +{"type":"assistant/chunk","seq":182,"time":1782133872836,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","arguments":"{\"description\": \"Reply with ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":183,"time":1782133872836,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":171,"outputTokens":173,"cacheReadTokens":1280,"reasoningTokens":98}}}} +{"type":"assistant/chunk","seq":184,"time":1782133872836,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":185,"time":1782133872836,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool with the prompt 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool with the prompt 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with the first one."},{"type":"tool-call","id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","arguments":"{\"description\": \"Reply with ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"usage":{"inputTokens":171,"outputTokens":173,"cacheReadTokens":1280,"reasoningTokens":98}}} +{"type":"tool/call","seq":186,"time":1782133872836,"data":{"turn":2,"step":1,"callId":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","arguments":"{\"description\": \"Reply with ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":187,"time":1782133874245,"data":{"turn":2,"step":1,"callId":"call_00_PoCyXrE8CAYDDrnx19eO7333","content":[{"type":"text","text":"ALPHA"}],"isError":false}} +{"type":"step/end","seq":188,"time":1782133874246,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":189,"time":1782133874246,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":190,"time":1782133875024,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":191,"time":1782133875024,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":192,"time":1782133875129,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":193,"time":1782133875155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":194,"time":1782133875156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":195,"time":1782133875156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":196,"time":1782133875156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":197,"time":1782133875180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":198,"time":1782133875180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":199,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":200,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":201,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":202,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":203,"time":1782133875206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":204,"time":1782133875207,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":205,"time":1782133875207,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":206,"time":1782133875207,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":207,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":208,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":209,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":210,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":211,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":212,"time":1782133875232,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":213,"time":1782133875257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":214,"time":1782133875258,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":215,"time":1782133875258,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":216,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":217,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":218,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":219,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":220,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Since"}}} +{"type":"assistant/chunk","seq":221,"time":1782133875307,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":222,"time":1782133875308,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":223,"time":1782133875333,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ked"}}} +{"type":"assistant/chunk","seq":224,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":225,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":226,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":227,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":228,"time":1782133875357,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":229,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":230,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":231,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":232,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" know"}}} +{"type":"assistant/chunk","seq":233,"time":1782133875385,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":234,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":235,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":236,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":237,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":238,"time":1782133875410,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} +{"type":"assistant/chunk","seq":239,"time":1782133875435,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":240,"time":1782133875435,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":241,"time":1782133875435,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":242,"time":1782133875510,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":243,"time":1782133875510,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":244,"time":1782133875534,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":245,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":246,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":247,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":248,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":249,"time":1782133875569,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":250,"time":1782133875570,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"Recall"}}} +{"type":"assistant/chunk","seq":251,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":252,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":253,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":254,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":255,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":256,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":257,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":258,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":259,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":260,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":261,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":262,"time":1782133875661,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":263,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":264,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":265,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":266,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":267,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":268,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":269,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":270,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":271,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":272,"time":1782133875686,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":273,"time":1782133875710,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":274,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":275,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":276,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":277,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":278,"time":1782133875736,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":279,"time":1782133875737,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":280,"time":1782133875737,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":281,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":282,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":283,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":284,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":285,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":286,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":287,"time":1782133875787,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":288,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork with the prompt about the project codeword. Since the forked child inherits this conversation, it should know the codeword is SAFFRON."}}}} +{"type":"assistant/chunk","seq":289,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":290,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":103,"outputTokens":139,"cacheReadTokens":1536,"reasoningTokens":51}}}} +{"type":"assistant/chunk","seq":291,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":292,"time":1782133875843,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork with the prompt about the project codeword. Since the forked child inherits this conversation, it should know the codeword is SAFFRON."},{"type":"tool-call","id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"usage":{"inputTokens":103,"outputTokens":139,"cacheReadTokens":1536,"reasoningTokens":51}}} +{"type":"tool/call","seq":293,"time":1782133875843,"data":{"turn":2,"step":2,"callId":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":294,"time":1782133877128,"data":{"turn":2,"step":2,"callId":"call_00_BW0xGt0pKCAONv8lM1rC1333","content":[{"type":"text","text":"SAFFRON"}],"isError":false}} +{"type":"step/end","seq":295,"time":1782133877128,"data":{"turn":2,"step":2}} +{"type":"step/start","seq":296,"time":1782133877128,"data":{"turn":2,"step":3}} +{"type":"assistant/chunk","seq":297,"time":1782133877923,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":298,"time":1782133877923,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":299,"time":1782133878022,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":300,"time":1782133878047,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":301,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":302,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":303,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":304,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":305,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":306,"time":1782133878072,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":307,"time":1782133878072,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":308,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":309,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":310,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":311,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":312,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":313,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":314,"time":1782133878123,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":315,"time":1782133878123,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":316,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":317,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":318,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":319,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":320,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":321,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":322,"time":1782133878174,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":323,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":324,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":325,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":326,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":327,"time":1782133878199,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":328,"time":1782133878199,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":329,"time":1782133878199,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":330,"time":1782133878200,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":331,"time":1782133878200,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":332,"time":1782133878200,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":333,"time":1782133878225,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":334,"time":1782133878225,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":335,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":336,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":337,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":338,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned. The first returned \"ALPHA\" and the second returned \"SAFFRON\". Now I need to reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":339,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":340,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":129,"outputTokens":40,"cacheReadTokens":1664,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":341,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":342,"time":1782133878227,"data":{"turn":2,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned. The first returned \"ALPHA\" and the second returned \"SAFFRON\". Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":129,"outputTokens":40,"cacheReadTokens":1664,"reasoningTokens":35}}} +{"type":"step/end","seq":343,"time":1782133878227,"data":{"turn":2,"step":3}} +{"type":"turn/end","seq":344,"time":1782133878227,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl new file mode 100644 index 0000000000..c0aad6ad3b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -0,0 +1,228 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" They"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specified"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" deleg"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ations"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sequentially"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"What"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mentioned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" earlier"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"?"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'.\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_PoCyXrE8CAYDDrnx19eO7333","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_PoCyXrE8CAYDDrnx19eO7333","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Since"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" know"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_BW0xGt0pKCAONv8lM1rC1333","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_BW0xGt0pKCAONv8lM1rC1333","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}}