From 80b7f929ea1cd82f68e47e47996bb6042cd1876f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:17 +0800 Subject: [PATCH 01/23] feat(session-persistence): expose readRaw for per-session artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence contract gains a concrete readRaw default (undefined for backends without a per-session artifact) and the JSONL backend overrides it with the decode of its physical zstd frames, so a consumer can read the stored artifact text verbatim — the session-log export depends on it. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 17 +++++- docs/subsystems/persistence.zh.md | 17 +++++- .../tool-cordis/src/api-catalog.ts | 8 +++ .../session-persistence-jsonl/src/index.ts | 58 ++++++++++++++++++- .../tests/jsonl.spec.ts | 20 +++++++ .../tests/zstd.spec.ts | 21 +++++++ .../session/session-persistence/src/index.ts | 28 +++++++++ scripts/gen-cordis-catalog.ts | 1 + 11 files changed, 171 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..e870a59292 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618 +config-catalog.md: 0d1d2ddde31a7ea806ec273007d7b5743a553e3b config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..0d1d2ddde3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1395,7 +1395,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:59`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..fa128a80d0 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: 25541139de02d2bd3ea743fe530e47a628002695 +persistence.zh.md: d76ff93f5564e7612fd7107cd74e3138c03542d8 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..25541139de 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -237,6 +237,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..d76ff93f55 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -237,6 +237,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 9e63aa3ea3..5f4d9a124b 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -688,6 +688,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, + { + signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', + }, { signature: 'abstract create(meta: SessionHeader): Promise', jsDoc: '/**\n * Register a new session\'s metadata. A backend MAY defer the physical write\n * until the first {@link append} (lazy materialization), in which case a\n * created-but-never-appended session is absent from {@link list}\n * — abandoned sessions leave nothing behind.\n * @param meta - the immutable header (id, version, cwd, lineage) to record.\n */', @@ -2739,6 +2743,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', }, + { + name: 'SessionRawArtifact', + declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8d4aad8e7a..b339f2d736 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -18,7 +18,8 @@ import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { @@ -233,6 +234,61 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** + * Read a session's stored artifact text verbatim: the durable file bytes + * decoded from this backend's physical encoding (complete zstd frames + * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the + * backend wrote — never a reconstruction from parsed events — so packed- + * chunk rows, key order, and line breaks survive byte-for-byte. A torn + * final frame is omitted, matching the committed-prefix semantics of every + * other read. + * @param id - the persisted session to read. + * @param signal - optional cancellation for the stat/read/decode work. + * @returns the raw artifact text plus the header parsed from its own first + * line, or `undefined` when the session has no stored artifact. + */ + override async readRaw(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + let buffer: Buffer + // Revision-stable read: a writer appending between stat and readFile + // would yield a torn physical file (see readPrefix). + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) break + } + let content: string + if (this.compression === 'zstd') { + const { frames } = scanZstdFrames(buffer) + if (frames.length === 0) return undefined + const decoder = createZstdFrameDecoder() + const plaintexts: Buffer[] = [] + // The decoder yields views into a reused buffer; copy each frame's + // plaintext immediately so a later concat cannot read overwritten memory. + for (const plaintext of decoder.decode(buffer, frames)) { + signal?.throwIfAborted() + plaintexts.push(Buffer.from(plaintext)) + } + content = Buffer.concat(plaintexts).toString('utf8') + } else { + content = buffer.toString('utf8') + } + const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) + if (meta === undefined || meta.id !== id) { + throw new Error(`corrupt session log: invalid header line in "${path}"`) + } + // The logical artifact name is `session.jsonl` regardless of the physical + // encoding suffix (`.jsonl.zstd` marks compression only). + return { meta, filename: 'session.jsonl', content } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index c7b4ab8841..390d0e9048 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -217,6 +217,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('readRaw returns the stored artifact text verbatim with its original filename', async () => { + const m = meta('raw-read', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(m.id) + // Byte-identical to the physical file — never a reconstruction. + expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) + expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for an absent session', async () => { + const m = meta('raw-missing', '/work') + expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b459fcac43..37d3e6c13b 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -356,6 +356,27 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) }) + it('readRaw decodes the compressed artifact back to the original JSONL text', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-read-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + + const raw = await ctx.sessionPersistence.readRaw(header.id) + expect(raw).toBeDefined() + // The logical name drops the physical encoding suffix. + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(header.id) + expect(raw!.content).toBe([ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(e => JSON.stringify(e)), + '', + ].join('\n')) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 5c3df73b30..0889a60edf 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -30,6 +30,16 @@ export interface SessionInspection { readonly events: readonly SessionEvent[] } +/** A backend's own raw artifact text for one session, verbatim. */ +export interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} + // The backend-agnostic write-path orchestration first-party backends compose. export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, @@ -83,6 +93,24 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined + /** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ + readRaw(_id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + return Promise.resolve(undefined) + } + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 885e0cf9d2..05aa92ac87 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -301,6 +301,7 @@ export const LINK_MAP: Readonly> = { SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', + SessionRawArtifact: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', From ded90bffbadb955cacaf89684135a991699c7a5f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:24 +0800 Subject: [PATCH 02/23] feat(apiproxy): host session-log download surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streams one ZIP of the root session artifact plus each subagent descendant verbatim (the persistence readRaw bytes) from GET /api/session.export as a host-only download — no wire envelope, absent from IApiClient. The downloads domain owns the query schema, the fetch handler answers the GET alongside the SSE routes, and compression runs on the host with fflate's streaming Zip API. --- THIRD_PARTY_NOTICES.md | 1 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 39 ++++ .../host/apiproxy/src/api/downloads.schema.ts | 22 +++ packages/host/apiproxy/src/api/downloads.ts | 25 +++ packages/host/apiproxy/src/api/index.ts | 9 +- packages/host/apiproxy/src/fetch/handler.ts | 12 ++ packages/host/apiproxy/src/index.ts | 2 + packages/host/apiproxy/src/session-export.ts | 176 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 + .../apiproxy/tests/session-export.spec.ts | 149 +++++++++++++++ pnpm-lock.yaml | 11 ++ 16 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 packages/host/apiproxy/src/api/downloads.schema.ts create mode 100644 packages/host/apiproxy/src/api/downloads.ts create mode 100644 packages/host/apiproxy/src/session-export.ts create mode 100644 packages/host/apiproxy/tests/session-export.spec.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fec5de6128..3da3747c24 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -59,6 +59,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index c30ee60ec2..6a7abf8bdc 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e -README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3 +README.md: 452e157ed93ca4ace07a8c958bad04b15d598b31 +README.zh.md: f0509276ef690874e788a3a1e312d35ec37034f0 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 64f6ae7bcd..452e157ed9 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,6 +28,8 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never materializes the whole archive. It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. + Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 680fcee730..f0509276ef 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,6 +28,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进内存。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 + 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 42f69c6211..31c907fb43 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "fflate": "^0.8.2", "schemastery": "^3.18.0", "zod": "^4.4.3" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5576f2e75c..6301b9f04c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,6 +41,12 @@ import type { QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' +import { + sessionLogExportDeps, + sessionLogZipFilename, + streamSessionLogZip, +} from './session-export.ts' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, @@ -3348,6 +3354,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + downloads: { + async sessionLog(request, signal) { + // Clean error path first: missing services answer 500 and a missing + // root artifact 404 before any zip byte is produced. The root content + // read here is reused as the first zip entry, so nothing is read twice. + const deps = sessionLogExportDeps(ctx) + if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined) { + return new Response( + 'session log export is unavailable: missing session-query or session-persistence service', + { status: 500 }, + ) + } + let root: SessionRawArtifact | undefined + try { + root = await deps.sessionPersistence.readRaw(request.sessionId) + } catch (error: unknown) { + return new Response(String(error), { status: 500 }) + } + if (root === undefined) { + return new Response('session not found', { status: 404 }) + } + return new Response( + streamSessionLogZip(deps, root, request.sessionId, request.includeDescendants === true, signal), + { + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, + }, + }, + ) + }, + }, + respond(message: ClientResponse): Promise { // Route by the echoed rpcId (the wire correlation): approvals first, // then questions — the two registries share one id space of UUIDs. diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts new file mode 100644 index 0000000000..d324711b78 --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -0,0 +1,22 @@ +/** + * downloads domain zod schemas. The GET download surface has no wire + * envelope: the request arrives as query parameters (all strings), so its + * request schema parses the raw query-parameter object into the method's + * exact request shape. SessionId brand cast point: sessionIdSchema, and only + * there (hosted in sessions.schema like every other cast). + */ + +import { z } from 'zod' +import type { DownloadsApi } from './downloads.ts' +import { sessionIdSchema } from './sessions.schema.ts' + +/** session.export query params → the sessionLog request. */ +export const sessionLogQuerySchema = z + .object({ + sessionId: sessionIdSchema, + includeDescendants: z.string().optional(), + }) + .transform(query => ({ + sessionId: query.sessionId, + ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}), + })) satisfies z.ZodType[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts new file mode 100644 index 0000000000..d0e6138b6a --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.ts @@ -0,0 +1,25 @@ +/** + * downloads domain contract: host-only download surfaces — the GET-download + * channel family, the mirror of the SSE-stream `events` domain. No wire + * envelope: the carrier's GET routes answer these directly, and the browser + * `IApiClient` never exposes them. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Host-only download surfaces (no wire envelope; absent from IApiClient). */ +export interface DownloadsApi { + /** + * Stream one session-log ZIP — the root artifact verbatim plus each subagent + * descendant's — as an attachment response. The carrier's GET route answers + * this directly; the browser never calls it. + * @param request - the root session id and whether to include descendants. + * @param signal - cancellation for the underlying reads. + * @returns the ZIP attachment response; missing services answer 500 and a + * missing root session 404 before any byte is produced. + */ + sessionLog( + request: { sessionId: SessionId; includeDescendants?: boolean }, + signal: AbortSignal, + ): Promise +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index e8eb3f3272..e41afafa9c 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -16,6 +16,7 @@ import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' +import type { DownloadsApi } from './downloads.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ @@ -32,6 +33,8 @@ export interface ApiProxy { settings: SettingsApi credentials: CredentialsApi llm: LlmApi + /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ + downloads: DownloadsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise } @@ -39,9 +42,8 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock, - SessionSearchItem, - SessionsApi, SessionSummary, + ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, + SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { @@ -57,6 +59,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' +export type { DownloadsApi } from './downloads.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f62a3584e7..1e902f059e 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import type { z } from 'zod' import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts' +import { sessionLogQuerySchema } from '../api/downloads.schema.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' @@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname + // No-envelope GET channel surface (SSE streams + host-only download): + // physical routes that answer directly, without a wire envelope. if (path === '/api/events.mux' && req.method === 'GET') { return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } if (path === '/api/events.host' && req.method === 'GET') { return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } + if (path === '/api/session.export' && req.method === 'GET') { + // Query params are a different boundary from the POST envelope, but + // the request still casts its brands only through the domain schema. + const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) + if (!parsed.success) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + return api.downloads.sessionLog(parsed.data, req.signal) + } if (req.method !== 'POST' || !path.startsWith('/api/')) { return new Response('not found', { status: 404 }) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 43ce9e2df4..06a7475dca 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] + readonly downloads: ApiProxy['downloads'] readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { @@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.credentials = api.credentials this.llm = api.llm this.events = api.events + this.downloads = api.downloads // createApiProxy returns closures (no `this` capture), so the bind is // behavior-neutral. this.respond = api.respond.bind(api) diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts new file mode 100644 index 0000000000..62d4ffcd05 --- /dev/null +++ b/packages/host/apiproxy/src/session-export.ts @@ -0,0 +1,176 @@ +/** + * Host-side session-log download: streams one ZIP archive whose files are the + * sessions' stored artifact text verbatim. The root artifact sits under its + * original base name (`session.jsonl`); each subagent descendant under + * `subagents//`. No manifest is written — every file is + * byte-identical to the backend's durable artifact and self-describing + * through its own header line. Compression happens on the host with fflate's + * streaming Zip API, so the response is chunked as it is produced and the + * host never materializes the whole archive. + * @module + */ + +import { Zip, ZipDeflate } from 'fflate' +import type { Context } from 'cordis' +import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' + +/** The services a session-log export needs (absent → the export is unavailable). */ +export interface SessionLogExportDeps { + readonly sessionQuery: SessionQueryService | undefined + readonly sessionPersistence: SessionPersistence | undefined +} + +/** + * Resolve the persistence and session-query services a log export needs. + * @param ctx - the composed host context. + * @returns the export services (absent when the deployment does not mount them). + */ +export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { + return { + sessionQuery: ctx.get('sessionQuery'), + sessionPersistence: ctx.get('sessionPersistence'), + } +} +/** One exported artifact: the stored text plus the zip path it lands at. */ +export interface SessionLogZipEntry { + /** Zip entry path (root filename verbatim; descendants under `subagents//`). */ + readonly path: string + /** The stored artifact text verbatim. */ + readonly content: string +} + +/** + * One safe zip path segment from an untrusted session id. Session ids are + * host-controlled, but the brand allows any non-empty string, so `../` and + * separator characters are neutralized before they can shape archive entries. + * @param id - the raw session id. + * @returns a filesystem-safe single path segment. + */ +function safeSessionIdSegment(id: string): string { + return id.replace(/[^A-Za-z0-9._-]/g, '_') +} + +/** + * The export archive filename for one root session. + * @param sessionId - the root session id (sanitized to one safe path segment). + * @returns the attachment filename for the session's export archive. + */ +export function sessionLogZipFilename(sessionId: string): string { + return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` +} +/** + * Yield the export entries in zip order: the preloaded root artifact first, + * then every subagent descendant in lineage order, each read from the + * persistence backend right before it is yielded and dropped after the + * consumer moves on (the host holds at most one descendant's artifact text at + * a time beyond the root). + * @param deps - the export services. + * @param root - the already-read root artifact (read by the caller so the + * missing-session path can answer cleanly before streaming starts). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation for read work. + * @returns the export entries in zip order. + */ +export async function* sessionLogZipEntries( + deps: SessionLogExportDeps, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): AsyncGenerator { + yield { path: root.filename, content: root.content } + if (!includeDescendants) return + const sessionQuery = deps.sessionQuery + const sessionPersistence = deps.sessionPersistence + if (sessionQuery === undefined || sessionPersistence === undefined) { + // The caller validated services before the stream started; this arm is + // unreachable today and guards a future caller that skips the check. + throw new Error('session log export is unavailable: missing session-query or session-persistence service') + } + const seen = new Set([sessionId]) + const collect = async function* ( + nodes: readonly SessionLineageNode[], + ): AsyncGenerator { + for (const node of nodes) { + signal?.throwIfAborted() + const id = node.session.header.id + if (seen.has(id)) continue + seen.add(id) + const raw = await sessionPersistence.readRaw(id) + if (raw === undefined) { + throw new Error(`subagent "${id}" has no stored log artifact`) + } + yield { + path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, + content: raw.content, + } + yield* collect(node.descendants) + } + } + const lineage = await sessionQuery.traceSession(sessionId) + yield* collect(lineage.descendants) +} + +/** How many code points of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_POINTS = 1 << 16 + +/** + * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is + * read and validated by the caller before this is called (missing root or + * missing services answer cleanly before any byte is produced); each entry is + * then encoded and deflated in bounded chunks as it is produced, so the + * archive bytes arrive incrementally. A descendant that fails to read errors + * the stream (fail-loud, never silent under-export). + * @param deps - the export services. + * @param root - the already-read root artifact (first zip entry). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation for read work. + * @returns the zip byte stream. + */ +export function streamSessionLogZip( + deps: SessionLogExportDeps, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + // fflate invokes the callback synchronously per compressed chunk; + // enqueued bytes stay bounded by the compressed archive size (the body + // consumer drains them over the wire as the stream is pulled). + const zip = new Zip((error, data, final) => { + if (error) { + controller.error(error) + return + } + if (data.byteLength > 0) controller.enqueue(data) + if (final) controller.close() + }) + void (async () => { + try { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { + const deflate = new ZipDeflate(entry.path, { level: 6 }) + zip.add(deflate) + const content = entry.content + for (let offset = 0; offset < content.length; offset += PUSH_CHUNK_CODE_POINTS) { + signal?.throwIfAborted() + const finalChunk = offset + PUSH_CHUNK_CODE_POINTS >= content.length + deflate.push(encoder.encode(content.slice(offset, offset + PUSH_CHUNK_CODE_POINTS)), finalChunk) + } + } + zip.end() + } catch (error) { + // A mid-stream failure (missing descendant, cancellation, read + // error) must fail the download rather than ship a truncated archive. + controller.error(error instanceof Error ? error : new Error(String(error))) + } + })() + }, + }) +} diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2b9249b7f0..9365006de4 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -133,6 +133,7 @@ function scriptedApi(overrides: { }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), + downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 63c40414d8..ed286334a8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -300,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, + downloads: { + async sessionLog() { + return new Response('stub', { status: 404 }) + }, + }, } } diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts new file mode 100644 index 0000000000..479e2ac178 --- /dev/null +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -0,0 +1,149 @@ +/** + * session.export host path: the GET download endpoint streams a ZIP whose + * files are the stored artifacts verbatim (root + optional descendants), and + * the degenerate compositions fail loudly (missing services → 500, missing + * root → 404, missing descendant → errored stream). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { unzipSync, strFromU8 } from 'fflate' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +function header(id: string, parentSession?: SessionId): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 1000, + cwd: '/proj', + ...parentSession === undefined ? {} : { parentSession }, + delegationDepth: parentSession === undefined ? 0 : 1, + } +} + +function artifact(id: string, parentSession?: SessionId): SessionRawArtifact { + return { + meta: header(id, parentSession), + filename: 'session.jsonl', + content: `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, + } +} + +function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { + return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } +} + +async function buildApi( + artifacts: Record, + descendants: SessionLineageNode[] = [], + services: { query?: boolean; persistence?: boolean } = { query: true, persistence: true }, +) { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + if (services.query) { + ctx.provide('sessionQuery', { + traceSession: async () => ({ + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants, + }), + } as never) + } + if (services.persistence) { + ctx.provide('sessionPersistence', { + readRaw: async (id: SessionId) => artifacts[id], + } as never) + } + return createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) +} + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('session.export download endpoint', () => { + it('streams a ZIP with the root artifact verbatim under its original filename', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + }) + + it('includes descendant artifacts under subagents// when requested', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'grandchild-a': artifact('grandchild-a', sid('child-a')), + }, [ + node('child-a', node('grandchild-a')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/grandchild-a/session.jsonl', + ]) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + .toBe(artifact('child-a').content) + }) + + it('answers 404 for a missing root session', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(404) + }) + + it('answers 400 when the sessionId query parameter is absent', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?includeDescendants=true'), + ) + expect(response.status).toBe(400) + }) + + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { + const api = await buildApi({}, [], { query: false, persistence: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('session-query') + }) + + it('fails the whole export when a descendant has no stored artifact', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + }, [node('child-missing')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + // The stream errors before completing, so the body read rejects rather + // than returning a truncated-but-valid archive. + await expect(response.arrayBuffer()).rejects.toThrow() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..a69b80cee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2727,6 +2727,9 @@ importers: specifier: ^9.0.0 version: 9.0.0 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -4292,6 +4295,9 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace + fflate: + specifier: ^0.8.2 + version: 0.8.3 schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery @@ -11521,6 +11527,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -16818,6 +16827,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 From 9574a99f45d6a2981e215b5a1d8a66f58312a825 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:49 +0800 Subject: [PATCH 03/23] feat(web): export the session log from the trajectory toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 导出 button fetches GET /api/session.export and saves the ZIP (root artifact plus subagent descendants). The plugin exposes exportLog through the view's inject face, resolves the tab label through the locale service, and disables the button while in flight; fixture mode answers 404 so the error bar explains the gap. --- .../navigation-panes/trajectory.expected.md | 1 + .../client/connection/src/client/fixture.ts | 6 ++ .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- packages/client/ui-trajectory/package.json | 3 + .../src/client/TrajectoryToolbar.module.css | 40 ++++++++++ .../src/client/TrajectoryToolbar.tsx | 23 ++++++ .../src/client/TrajectoryView.tsx | 27 ++++++- .../ui-trajectory/src/client/export-log.ts | 40 ++++++++++ .../client/ui-trajectory/src/client/index.ts | 33 +++++++- .../ui-trajectory/src/client/locales.ts | 24 ++++++ .../ui-trajectory/src/client/views.module.css | 12 +++ .../ui-trajectory/tests/client-bundle.spec.ts | 7 +- .../ui-trajectory/tests/export-log.spec.ts | 20 +++++ .../ui-trajectory/tests/toolbar.spec.tsx | 55 +++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 77 +++++++++++++++++++ packages/client/ui-trajectory/tsconfig.json | 3 + 18 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/export-log.ts create mode 100644 packages/client/ui-trajectory/src/client/locales.ts create mode 100644 packages/client/ui-trajectory/tests/export-log.spec.ts create mode 100644 packages/client/ui-trajectory/tests/toolbar.spec.tsx diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index a9b5dbb982..8e6fa01d68 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,6 +2,7 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls + - button "导出会话日志": 导出 - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f149e28984..503cc8cc6b 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,6 +2834,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, + // The host-only streaming download has no in-memory counterpart: fixture + // mode answers 404 so the export button's error bar explains the gap + // instead of hanging. + downloads: { + sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), + }, } const rpc: ClientConnectionRpc = { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 7fecd92d74..1caa4f316c 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 5b8c0cd111c272007212fea0d2435c5fab2360ab -README.zh.md: 9aaa02ccd9d50b0f0b23e9a53ea9b1048d0e513f +README.md: fe63c58b54a23a3faa4180e11ceec1d6409c8160 +README.zh.md: ed379fbe133ce27ea3f70c16a647fdd294ba6ca8 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5b8c0cd111..fe63c58b54 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The toolbar's 导出 button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact). Fixture mode (no host) answers 404 for the export. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9aaa02ccd9..ed379fbe13 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。工具栏的「导出」按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致)。fixture 模式(无宿主)对导出应答 404。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index f56a9825ce..4ec9721691 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -39,6 +40,7 @@ "diff": "^9.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -47,6 +49,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index aa38650411..6e213604cf 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -164,6 +164,46 @@ font: 14px/14px var(--ds-font-family-code); } +.export { + display: inline-flex; + flex: none; + align-items: center; + height: 20px; + padding: 0 7px; + gap: 4px; + border: 0; + border-radius: 3px; + color: var(--dsw-alias-label-tertiary); + background: transparent; + cursor: pointer; + font: var(--dsw-font-xxs-12); +} + +.export:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.export:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.export:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: wait; +} + +.exportIcon { + flex: none; + width: 12px; + height: 12px; + stroke: currentColor; + stroke-width: 1.25; + stroke-linecap: round; + stroke-linejoin: round; +} + .search { display: flex; flex: 0 1 164px; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index 5db9f56e80..fd05582b4b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -24,6 +24,12 @@ export interface TrajectoryToolbarProps { searchQuery: string /** Update the live ledger search query. */ onSearchQueryChange: (query: string) => void + /** Whether the session-log export is in flight. */ + exporting: boolean + /** Trigger the session-log export download. */ + onExport: () => void + /** Export failure message, shown while set; null while idle or successful. */ + exportError: string | null } /** @@ -42,6 +48,9 @@ export function TrajectoryToolbar({ onToggleAllAssistants, searchQuery, onSearchQueryChange, + exporting, + onExport, + exportError, }: TrajectoryToolbarProps) { return (
@@ -105,6 +114,20 @@ export function TrajectoryToolbar({ Calls +
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 6d77f907f1..9d182b379b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -73,6 +73,8 @@ export interface TrajectoryViewInjected { loadHistoryTail: (signal: AbortSignal) => Promise loadOlderHistory: (signal: AbortSignal) => Promise setActualDuration: (actualDuration: boolean) => void + /** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */ + exportLog: () => Promise } interface UsageLike { @@ -184,7 +186,7 @@ function mergeSearchMatches( } export function TrajectoryView({ - useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, + useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog, inspect, onInspectDone, }: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) @@ -197,6 +199,8 @@ export function TrajectoryView({ const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [exporting, setExporting] = useState(false) + const [exportError, setExportError] = useState(null) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState(null) const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ readonly index: number @@ -524,6 +528,19 @@ export function TrajectoryView({ : Promise.resolve(false) }, [loadOlderHistory]) + const onExport = useCallback(() => { + if (exporting) return + setExporting(true) + setExportError(null) + void exportLog().then( + () => { setExporting(false) }, + (error: unknown) => { + setExportError(error instanceof Error ? error.message : String(error)) + setExporting(false) + }, + ) + }, [exportLog, exporting]) + return (
+ {exportError !== null && ( +
+ {exportError} +
+ )} , filename: string, type: string): void { + const url = URL.createObjectURL(new Blob([bytes], { type })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index a6b5a4282d..adfb20fb02 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -4,14 +4,18 @@ */ import type { Context } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +import { en, NS, zh } from './locales.ts' +import { downloadBytes, sessionLogZipFilename } from './export-log.ts' -/** Required services: the conversation view slot and independent history source. */ -export const inject = ['slots', 'sessionHistory'] +/** Required services: the conversation view slot, the independent history source, and the locale service. */ +export const inject = ['slots', 'sessionHistory', 'locale'] /** * Client plugin body: register the trajectory view tab. The registration @@ -19,12 +23,17 @@ export const inject = ['slots', 'sessionHistory'] * @param ctx - client root context. */ export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries') + // Registration-time text (the view tab label) reads through the bound + // translate as a thunk, so it follows the active locale without + // re-registration. + const t = ctx.locale.bind(NS) const duration = createTrajectoryDurationStore() ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, - label: 'Trajectory', + label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { const history = ctx.sessionHistory.source(sessionId) return { @@ -32,6 +41,24 @@ export function apply(ctx: Context): void { loadHistoryTail: signal => history.loadTail(signal), loadOlderHistory: signal => history.loadOlder(signal), setActualDuration: (value) => { duration.set(value) }, + exportLog: async () => { + // The host streams the ZIP (root + descendant artifacts verbatim) + // from GET /api/session.export; the browser downloads the response. + const url = new URL('/api/session.export', window.location.origin) + url.searchParams.set('sessionId', sessionId) + url.searchParams.set('includeDescendants', 'true') + const response = await fetch(url) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) + } + const blob = await response.blob() + downloadBytes( + new Uint8Array(await blob.arrayBuffer()), + sessionLogZipFilename(sessionId), + 'application/zip', + ) + }, } }, }, TrajectoryView)) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts new file mode 100644 index 0000000000..f5660dfddf --- /dev/null +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -0,0 +1,24 @@ +/** `trajectory` namespace dictionaries (the view tab label). */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'trajectory' + +/** The trajectory dictionary key set (the source of truth for both locales). */ +export type TrajectoryKey = 'view.trajectory' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The trajectory view tab label. */ + 'trajectory': TrajectoryKey + } +} + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh: Record = { + 'view.trajectory': '轨迹', +} + +/** English dictionary. */ +export const en: Record = { + 'view.trajectory': 'Trajectory', +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 687f4a4657..842486ea93 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,6 +13,18 @@ background: var(--dsw-alias-bg-layer-1); } +.exportError { + box-sizing: border-box; + flex: none; + width: 100%; + padding: 4px 10px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary)); + background: var(--dsw-alias-bg-layer-2); + font: var(--dsw-font-xxs-12); + overflow-wrap: anywhere; +} + .ledger { position: relative; z-index: 0; diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 579a97337a..ebe08ad920 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -61,7 +61,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'sessionHistory']) + expect(surface.inject).toEqual(['slots', 'sessionHistory', 'locale']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -74,8 +74,11 @@ describe('tsdown client artifact', () => { children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) // The plugin reads sessionHistory for its per-session history source; - // slot availability is tracked by slots.inject. + // slot availability is tracked by slots.inject, and the locale plugin + // backs the locale-aware view tab label. ctx.provide('sessionHistory', {}) + const locale = await import('@deepseek-ai/dsh-client-locale/client') + ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts new file mode 100644 index 0000000000..e693ded89b --- /dev/null +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -0,0 +1,20 @@ +// @vitest-environment node +/** + * Session-log export filename derivation. The archive itself is produced and + * streamed by the host (GET /api/session.export); this package only derives + * the download filename and triggers the browser save. + */ + +import { describe, expect, it } from 'vitest' +import { sessionLogZipFilename } from '../src/client/export-log.ts' + +describe('sessionLogZipFilename', () => { + it('keeps safe session ids verbatim', () => { + expect(sessionLogZipFilename('session-abc_1.2')).toBe('dsh-session-session-abc_1.2.zip') + }) + + it('neutralizes unsafe id characters that could shape the filename', () => { + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip') + expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') + }) +}) diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx new file mode 100644 index 0000000000..b3e2a4c014 --- /dev/null +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function baseProps(overrides: Partial = {}): TrajectoryToolbarProps { + return { + actualDuration: false, + onActualDurationChange: vi.fn(), + actualTime: false, + onActualTimeChange: vi.fn(), + allTurnsCollapsed: false, + onToggleAllTurns: vi.fn(), + allAssistantsCollapsed: false, + onToggleAllAssistants: vi.fn(), + searchQuery: '', + onSearchQueryChange: vi.fn(), + exporting: false, + onExport: vi.fn(), + exportError: null, + ...overrides, + } +} + +describe('TrajectoryToolbar export', () => { + it('renders the export button and dispatches the export callback on click', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: '导出会话日志' }) + fireEvent.click(button) + expect(onExport).toHaveBeenCalledTimes(1) + }) + + it('disables the button while an export is in flight and blocks dispatch', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: '导出会话日志' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(onExport).not.toHaveBeenCalled() + }) + + it('surfaces an export failure as the button title', () => { + render() + const button = screen.getByRole('button', { name: '导出会话日志' }) + expect(button.title).toBe('导出失败:internal boom') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c9952be580..9c52417b61 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -27,6 +27,7 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -111,6 +112,12 @@ function standaloneDuration(): Pick< } } +function standaloneExport( + onExport: () => Promise = vi.fn(() => Promise.resolve()), +): Pick, 'exportLog'> { + return { exportLog: onExport } +} + function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore({ nodes, pending: [], partial: null, @@ -168,6 +175,8 @@ async function bench(snapshot = historySnapshot(NODES)) { slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) ctx.provide('sessionHistory', { source: () => history }) + // The locale plugin backs the locale-aware view tab label ('locale' in inject). + ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } @@ -218,6 +227,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES loadHistoryTail: trajectory.loadHistoryTail, loadOlderHistory: trajectory.loadOlderHistory, setActualDuration: trajectory.setActualDuration, + exportLog: trajectory.exportLog, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), } @@ -331,6 +341,17 @@ describe('tab switching in ConversationRoot', () => { expect(signal?.aborted).toBe(true) }) + it('labels the trajectory tab in the active locale', async () => { + const b = await bench() + const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label + expect(labelOf()).toBe('Trajectory') + const locale = b.ctx.get('locale') as { setLocale(id: string): void } + locale.setLocale('zh') + expect(labelOf()).toBe('轨迹') + locale.setLocale('en') + expect(labelOf()).toBe('Trajectory') + }) + it('opens a local record inspector and switches payload tabs without opening chat details', async () => { const b = await bench() mount(b.slots) @@ -1066,6 +1087,7 @@ describe('timeline projection', () => { ...standaloneProps([]), ...standaloneHistory(historySnapshot([])), ...standaloneDuration(), + ...standaloneExport(), }, )) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() @@ -1073,6 +1095,55 @@ describe('timeline projection', () => { }) }) +describe('session log export', () => { + afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(URL, 'createObjectURL') + Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click') + }) + + it('downloads the host-streamed ZIP with descendants on click', async () => { + // exportLog always fetches a URL instance, so the mock's shape stays narrow. + const fetchMock = vi.fn(async (input: URL) => { + expect(input.pathname).toBe('/api/session.export') + expect(input.searchParams.get('sessionId')).toBe(SID) + expect(input.searchParams.get('includeDescendants')).toBe('true') + return new Response('zip-bytes') + }) + vi.stubGlobal('fetch', fetchMock) + const createObjectURL = vi.fn(() => 'blob:export') + URL.createObjectURL = createObjectURL + const clickAnchor = vi.fn() + HTMLAnchorElement.prototype.click = clickAnchor + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: '导出会话日志' })) + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledOnce() + }) + // The blob download lands a few microtasks after the fetch settles. + // The blob download lands a few microtasks after the fetch settles. + await vi.waitFor(() => { + expect(createObjectURL).toHaveBeenCalled() + }) + expect(clickAnchor).toHaveBeenCalled() + }) + + it('surfaces the download failure in the visible alert bar', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 }))) + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: '导出会话日志' })) + await vi.waitFor(() => { + const alert = screen.queryByRole('alert') + expect(alert).not.toBeNull() + expect(alert!.textContent).toContain('HTTP 404') + }) + }) +}) + describe('TrajectoryView branches', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() @@ -1083,6 +1154,7 @@ describe('TrajectoryView branches', () => { const first = render( { firstDuration.set(value) }} />, @@ -1098,6 +1170,7 @@ describe('TrajectoryView branches', () => { render( { restoredDuration.set(value) }} />, @@ -1162,6 +1235,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} @@ -1196,6 +1270,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} @@ -1225,6 +1300,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} @@ -1274,6 +1350,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index f525474d9d..0d2dccb561 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-conversation" }, From ced7caabf8f5a5447b490d998208262e55084caa Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:55 +0800 Subject: [PATCH 04/23] docs(notes): agent note for web session-log export --- ...026-08-10-web-session-log-export.i18n.yaml | 6 ++++ .../2026-08-10-web-session-log-export.md | 30 +++++++++++++++++++ .../2026-08-10-web-session-log-export.zh.md | 30 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml new file mode 100644 index 0000000000..60c70d5519 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +2026-08-10-web-session-log-export.md: 9d31e4deb640474230dd9b16ec02f2f36dc5ca56 +2026-08-10-web-session-log-export.zh.md: 0a2ba678b0ea56202493507165d94750761ce3d9 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md new file mode 100644 index 0000000000..9d31e4deb6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -0,0 +1,30 @@ +# Agent Note: Web session-log export via session.log + ZIP download + +Status: implemented + +English | [中文](2026-08-10-web-session-log-export.zh.md) + +## Problem + +The Trajectory view had no way to hand a debugging artifact to a human: the raw session log lived on disk and in the host, the client history face served folded projections (not raw entries), and a session with subagents spans many independent session logs. A bug report needs the complete raw log of the whole tree, in a shape that survives being emailed around. + +## Decision + +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never materializes the whole archive (at most one descendant's artifact text beyond the preloaded root). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot). +- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar. + +## Alternatives considered + +- **`session.log` data RPC + client-side zip** — shipped first, rejected with the user: the browser pulls the full raw JSON (≈10× the final zip size) and compresses on the main thread; for the 23 MB sessions in real use the host-side stream is strictly better. The RPC was deleted with the migration rather than left as a dead public surface. +- **Single JSONL with envelope lines for multiple sessions** — rejected with the user: mixing sessions in one JSONL loses clean per-file boundaries; a ZIP keeps one canonical file per session. +- **jszip** — heavier (~100 kB) and its dependency graph pulls readable-stream browser mappings; fflate is purpose-built and small. +- **Vendoring fflate's browser entry** — the repo vendoring procedure targets cordis-scale pinned sources; a resolveId alias keeps the maintained dependency without shipping a copy (and host-side fflate needs no alias at all). + +## Consequences + +- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. +- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button. +- Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md new file mode 100644 index 0000000000..0a2ba678b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Web 会话日志导出——session.log RPC + ZIP 下载 + +状态:implemented + +[English](2026-08-10-web-session-log-export.md) | 中文 + +## 问题 + +Trajectory 视图没有任何方式把调试工件交到人手里:原始会话日志存放在磁盘与宿主侧,客户端历史面只提供折叠后的投影(而非原始事件),而带子代理的会话横跨多个相互独立的会话日志。bug 报告需要整棵会话树的完整原始日志,并且形态要能在被转发后仍然可用。 + +## 决策 + +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进内存(除预载的根外,最多同时持有一条后代的工件文本)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 +- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 + +## 考虑过的替代方案 + +- **`session.log` 数据 RPC + 客户端打包**——先发布,后与用户共同否决:浏览器要拉取完整原始 JSON(约为最终 zip 的 10 倍)并在主线程压缩;对实际使用中 23 MB 级别的会话,宿主流式严格更优。迁移时把该 RPC 一并删除,而不是留作无消费者的公共接口。 +- **用信封行把多会话编码进单一 JSONL**——与用户共同否决:把多个会话混进一个 JSONL 会失去干净的按文件边界;ZIP 让每个会话保持一个规范文件。 +- **jszip**——更重(约 100 kB),依赖图还会拉入 readable-stream 的浏览器映射;fflate 专为此而生且体积小。 +- **将 fflate 浏览器入口 vendoring 进仓库**——仓库的 vendoring 流程面向 cordis 级别的固定源码;resolveId 别名在保持维护中的依赖的同时无需复制代码(宿主侧 fflate 根本不需要别名)。 + +## 后果 + +- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 +- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- fixture 模式(无宿主)对导出应答 404,按钮的错误条会解释这个缺口而非挂起;navigation-panes golden 快照包含「导出」按钮。 +- 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 From bf70da473b9ed19eed58fa25e54fb89ca893435e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:49:57 +0800 Subject: [PATCH 05/23] fix(session-persistence): async readRaw default and full branch coverage The inherited readRaw default now rejects like the async backend overrides instead of throwing synchronously, and its arms plus the JSONL override's retry loop, zero-frame, and corrupt-header branches get dedicated tests. --- .../tests/jsonl.spec.ts | 20 +++++++++++++++++++ .../tests/zstd.spec.ts | 12 +++++++++++ .../session/session-persistence/src/index.ts | 4 ++-- .../tests/persistence.spec.ts | 12 +++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 390d0e9048..7f760c7281 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -237,6 +237,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() }) + it('readRaw rejects a corrupt header line instead of exporting it', async () => { + const m = meta('raw-corrupt', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, '/work', m.id), 'not a header line\n{"type":"turn/start","seq":0}\n') + await expect(ctx.sessionPersistence.readRaw(m.id)).rejects.toThrow(/corrupt session log/) + }) + + it('readRaw retries when the file revision changes during the read', async () => { + const m = meta('raw-revision-race', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + statRace.path = rawLogPath(root, '/work', m.id) + + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + // Two stat calls per iteration; the mocked revision change forces a retry. + expect(statRace.reads).toBe(4) + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 37d3e6c13b..c54dca4c08 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -377,6 +377,18 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) }) + it('readRaw is undefined for a zstd artifact that carries no frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + // Overwrite the physical artifact with a short buffer: frame scanning + // answers zero frames before any magic check, so readRaw reports no artifact. + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined() + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 0889a60edf..aedfd8df5a 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -106,9 +106,9 @@ export abstract class SessionPersistence extends Service { * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ - readRaw(_id: SessionId, signal?: AbortSignal): Promise { + async readRaw(_id: SessionId, signal?: AbortSignal): Promise { signal?.throwIfAborted() - return Promise.resolve(undefined) + return undefined } /** diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index d3e715b085..99f6acc756 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -246,6 +246,18 @@ runPersistenceContract('memory', async () => { } }) +describe('the inherited readRaw default', () => { + it('answers undefined and honors an aborted signal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined() + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), + ).rejects.toThrow() + }) +}) + // Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are // atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => { From beb1d0601fb0516c187594d084def70abb6e6175 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:09 +0800 Subject: [PATCH 06/23] =?UTF-8?q?fix(apiproxy):=20address=20session-export?= =?UTF-8?q?=20review=20=E2=80=94=20surrogate-safe=20chunks,=20backpressure?= =?UTF-8?q?=20drain,=20strict=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk boundaries never split a surrogate pair (a lone high surrogate re-encodes as U+FFFD and silently corrupts the exported artifact), production yields whenever the response queue fills so a slow consumer bounds the accumulation, includeDescendants rejects values other than true/false instead of silently under-exporting, the dead missing-services arm is deleted by narrowing the streaming deps, and the readRaw failure answers 500 without leaking host paths into the browser error bar. --- packages/host/apiproxy/src/api-proxy.ts | 15 ++- .../host/apiproxy/src/api/downloads.schema.ts | 8 +- packages/host/apiproxy/src/session-export.ts | 103 ++++++++++++------ .../apiproxy/tests/session-export.spec.ts | 84 +++++++++++++- 4 files changed, 171 insertions(+), 39 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6301b9f04c..b5dcdbb4e2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -45,6 +45,7 @@ import { sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip, + type SessionLogExportReady, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { @@ -3366,17 +3367,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro { status: 500 }, ) } + const ready: SessionLogExportReady = { + sessionQuery: deps.sessionQuery, + sessionPersistence: deps.sessionPersistence, + } let root: SessionRawArtifact | undefined try { - root = await deps.sessionPersistence.readRaw(request.sessionId) - } catch (error: unknown) { - return new Response(String(error), { status: 500 }) + root = await deps.sessionPersistence.readRaw(request.sessionId, signal) + } catch { + // Backend read failure: answer 500 without echoing the error, which + // may carry absolute host paths into the browser error bar. + return new Response('session log export failed to read the stored artifact', { status: 500 }) } if (root === undefined) { return new Response('session not found', { status: 404 }) } return new Response( - streamSessionLogZip(deps, root, request.sessionId, request.includeDescendants === true, signal), + streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal), { headers: { 'content-type': 'application/zip', diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts index d324711b78..8a5b371e7f 100644 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -10,11 +10,15 @@ import { z } from 'zod' import type { DownloadsApi } from './downloads.ts' import { sessionIdSchema } from './sessions.schema.ts' -/** session.export query params → the sessionLog request. */ +/** + * session.export query params → the sessionLog request. `includeDescendants` + * accepts exactly `true`/`false`/absent; any other value is rejected (400) so + * a misspelled flag cannot silently under-export. + */ export const sessionLogQuerySchema = z .object({ sessionId: sessionIdSchema, - includeDescendants: z.string().optional(), + includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(), }) .transform(query => ({ sessionId: query.sessionId, diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 62d4ffcd05..1386d511b2 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -4,9 +4,13 @@ * original base name (`session.jsonl`); each subagent descendant under * `subagents//`. No manifest is written — every file is * byte-identical to the backend's durable artifact and self-describing - * through its own header line. Compression happens on the host with fflate's - * streaming Zip API, so the response is chunked as it is produced and the - * host never materializes the whole archive. + * through its own header line. Compression runs on the host with fflate's + * streaming Zip API, so the archive bytes are produced incrementally and the + * host never holds the whole archive in one buffer; production yields to the + * consumer whenever the response queue fills past its high-water mark, so a + * slow consumer bounds the accumulation instead of piling up the whole + * archive (fflate's callback is synchronous — this drain point is the only + * backpressure available). * @module */ @@ -22,6 +26,12 @@ export interface SessionLogExportDeps { readonly sessionPersistence: SessionPersistence | undefined } +/** The export services narrowed to the mounted ones streaming actually reads. */ +export interface SessionLogExportReady { + readonly sessionQuery: SessionQueryService + readonly sessionPersistence: SessionPersistence +} + /** * Resolve the persistence and session-query services a log export needs. * @param ctx - the composed host context. @@ -33,6 +43,7 @@ export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { sessionPersistence: ctx.get('sessionPersistence'), } } + /** One exported artifact: the stored text plus the zip path it lands at. */ export interface SessionLogZipEntry { /** Zip entry path (root filename verbatim; descendants under `subagents//`). */ @@ -43,13 +54,15 @@ export interface SessionLogZipEntry { /** * One safe zip path segment from an untrusted session id. Session ids are - * host-controlled, but the brand allows any non-empty string, so `../` and - * separator characters are neutralized before they can shape archive entries. + * host-controlled, but the brand allows any non-empty string, so `../`, dot + * segments, and separator characters are neutralized before they can shape + * archive entries. Distinct ids may collapse onto one segment (id collision + * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). * @param id - the raw session id. * @returns a filesystem-safe single path segment. */ function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9._-]/g, '_') + return id.replace(/[^A-Za-z0-9_-]/g, '_') } /** @@ -60,13 +73,14 @@ function safeSessionIdSegment(id: string): string { export function sessionLogZipFilename(sessionId: string): string { return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` } + /** * Yield the export entries in zip order: the preloaded root artifact first, * then every subagent descendant in lineage order, each read from the * persistence backend right before it is yielded and dropped after the * consumer moves on (the host holds at most one descendant's artifact text at * a time beyond the root). - * @param deps - the export services. + * @param deps - the mounted export services (the caller answered 500 before this runs). * @param root - the already-read root artifact (read by the caller so the * missing-session path can answer cleanly before streaming starts). * @param sessionId - the root session id. @@ -75,7 +89,7 @@ export function sessionLogZipFilename(sessionId: string): string { * @returns the export entries in zip order. */ export async function* sessionLogZipEntries( - deps: SessionLogExportDeps, + deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, @@ -83,13 +97,6 @@ export async function* sessionLogZipEntries( ): AsyncGenerator { yield { path: root.filename, content: root.content } if (!includeDescendants) return - const sessionQuery = deps.sessionQuery - const sessionPersistence = deps.sessionPersistence - if (sessionQuery === undefined || sessionPersistence === undefined) { - // The caller validated services before the stream started; this arm is - // unreachable today and guards a future caller that skips the check. - throw new Error('session log export is unavailable: missing session-query or session-persistence service') - } const seen = new Set([sessionId]) const collect = async function* ( nodes: readonly SessionLineageNode[], @@ -99,7 +106,7 @@ export async function* sessionLogZipEntries( const id = node.session.header.id if (seen.has(id)) continue seen.add(id) - const raw = await sessionPersistence.readRaw(id) + const raw = await deps.sessionPersistence.readRaw(id) if (raw === undefined) { throw new Error(`subagent "${id}" has no stored log artifact`) } @@ -110,12 +117,48 @@ export async function* sessionLogZipEntries( yield* collect(node.descendants) } } - const lineage = await sessionQuery.traceSession(sessionId) + const lineage = await deps.sessionQuery.traceSession(sessionId) yield* collect(lineage.descendants) } -/** How many code points of artifact text one zip push carries (bounded encode memory). */ -const PUSH_CHUNK_CODE_POINTS = 1 << 16 +/** How many code units of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_UNITS = 1 << 16 + +/** + * Push one artifact's text into a deflate stream in bounded chunks, never + * splitting a surrogate pair across a chunk boundary (a lone high surrogate + * re-encodes as U+FFFD and would silently corrupt the exported artifact). + * @param deflate - the zip entry's deflate stream. + * @param content - the artifact text verbatim. + * @param signal - optional cancellation; throws when aborted. + */ +async function pushArtifactChunks( + deflate: ZipDeflate, + content: string, + controller: ReadableStreamDefaultController, + signal?: AbortSignal, +): Promise { + const encoder = new TextEncoder() + let offset = 0 + let finalChunk: boolean + do { + signal?.throwIfAborted() + let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) + if (end < content.length && end - offset > 1) { + // Back off one code unit when the boundary lands inside a surrogate + // pair: the pair then starts the next chunk whole. + const last = content.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + } + finalChunk = end >= content.length + deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) + offset = end + /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ + if (controller.desiredSize !== null && controller.desiredSize < 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } while (!finalChunk) +} /** * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is @@ -124,7 +167,7 @@ const PUSH_CHUNK_CODE_POINTS = 1 << 16 * then encoded and deflated in bounded chunks as it is produced, so the * archive bytes arrive incrementally. A descendant that fails to read errors * the stream (fail-loud, never silent under-export). - * @param deps - the export services. + * @param deps - the mounted export services (the caller answered 500 before this runs). * @param root - the already-read root artifact (first zip entry). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. @@ -132,23 +175,25 @@ const PUSH_CHUNK_CODE_POINTS = 1 << 16 * @returns the zip byte stream. */ export function streamSessionLogZip( - deps: SessionLogExportDeps, + deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal, ): ReadableStream { - const encoder = new TextEncoder() return new ReadableStream({ start(controller) { - // fflate invokes the callback synchronously per compressed chunk; - // enqueued bytes stay bounded by the compressed archive size (the body - // consumer drains them over the wire as the stream is pulled). + // fflate invokes the callback synchronously per compressed chunk, so a + // single push can enqueue ahead of a slow consumer; pushArtifactChunks + // yields between chunks once the queue is over-full, bounding the + // accumulation to the queue high-water mark plus one push. const zip = new Zip((error, data, final) => { + /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ if (error) { controller.error(error) return } + /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ if (data.byteLength > 0) controller.enqueue(data) if (final) controller.close() }) @@ -157,17 +202,13 @@ export function streamSessionLogZip( for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { const deflate = new ZipDeflate(entry.path, { level: 6 }) zip.add(deflate) - const content = entry.content - for (let offset = 0; offset < content.length; offset += PUSH_CHUNK_CODE_POINTS) { - signal?.throwIfAborted() - const finalChunk = offset + PUSH_CHUNK_CODE_POINTS >= content.length - deflate.push(encoder.encode(content.slice(offset, offset + PUSH_CHUNK_CODE_POINTS)), finalChunk) - } + await pushArtifactChunks(deflate, entry.content, controller, signal) } zip.end() } catch (error) { // A mid-stream failure (missing descendant, cancellation, read // error) must fail the download rather than ship a truncated archive. + /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ controller.error(error instanceof Error ? error : new Error(String(error))) } })() diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 479e2ac178..40070beaaa 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -43,7 +43,7 @@ function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageN async function buildApi( artifacts: Record, descendants: SessionLineageNode[] = [], - services: { query?: boolean; persistence?: boolean } = { query: true, persistence: true }, + services: { query?: boolean; persistence?: boolean | 'throw' } = { query: true, persistence: true }, ) { const ctx = new Context() await ctx.plugin(UserInteractionService) @@ -60,7 +60,10 @@ async function buildApi( } if (services.persistence) { ctx.provide('sessionPersistence', { - readRaw: async (id: SessionId) => artifacts[id], + readRaw: async (id: SessionId) => { + if (services.persistence === 'throw') throw new Error('/host/private/session.jsonl') + return artifacts[id] + }, } as never) } return createApiProxy(ctx, { @@ -125,6 +128,14 @@ describe('session.export download endpoint', () => { expect(response.status).toBe(400) }) + it('answers 400 for an includeDescendants value other than true or false', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), + ) + expect(response.status).toBe(400) + }) + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { const api = await buildApi({}, [], { query: false, persistence: false }) const response = await toFetchHandler(api).fetch( @@ -146,4 +157,73 @@ describe('session.export download endpoint', () => { // than returning a truncated-but-valid archive. await expect(response.arrayBuffer()).rejects.toThrow() }) + + it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { + // The push loop slices by 2^16 code units and must back off one unit when + // the boundary lands inside a surrogate pair; otherwise the pair re-encodes + // as U+FFFD and the exported artifact is silently corrupted. + const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + // A boundary that lands on a BMP character needs no surrogate backoff; the + // round trip must still be byte-identical across the multi-chunk push. + const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('exports an empty artifact as an empty zip entry', async () => { + const root = { ...artifact('session-root'), content: '' } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + }) + + it('exports a shared lineage node once (seen-set dedup)', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'child-b': artifact('child-b', sid('session-root')), + shared: artifact('shared', sid('child-a')), + }, [ + node('child-a', node('shared')), + node('child-b', node('shared')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/child-b/session.jsonl', + 'subagents/shared/session.jsonl', + ]) + }) + + it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + const api = await buildApi({}, [], { query: true, persistence: 'throw' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to read the stored artifact') + expect(body).not.toContain('/host/private/') + }) }) From 53d9810461017799a85704642fdcf7f86d6aadbc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:20 +0800 Subject: [PATCH 07/23] fix(web): localize the trajectory toolbar and simplify the download Toolbar strings route through the locale dictionary's standard t seat (the export button no longer mixes languages in the English golden), exportLog passes the response blob straight to the browser save instead of copying it three times, the client id sanitizer rejects dot segments like the host one, and the fixture stub comment no longer misattributes the 404. --- .../client/connection/src/client/fixture.ts | 6 +- .../src/client/TrajectoryToolbar.tsx | 37 +++++++----- .../src/client/TrajectoryView.tsx | 7 ++- .../ui-trajectory/src/client/export-log.ts | 16 ++--- .../client/ui-trajectory/src/client/index.ts | 16 ++--- .../ui-trajectory/src/client/locales.ts | 58 ++++++++++++++++++- .../ui-trajectory/tests/export-log.spec.ts | 8 ++- .../ui-trajectory/tests/toolbar.spec.tsx | 6 ++ .../client/ui-trajectory/tests/views.spec.tsx | 30 ++++++---- 9 files changed, 130 insertions(+), 54 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 503cc8cc6b..08837db9c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,9 +2834,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, - // The host-only streaming download has no in-memory counterpart: fixture - // mode answers 404 so the export button's error bar explains the gap - // instead of hanging. + // Satisfies the ApiProxy contract type only: the browser export button + // fetches GET /api/session.export directly (window.fetch), so this stub is + // never reached through the fixture's dispatch. downloads: { sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index fd05582b4b..2ff7a6092c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -1,6 +1,8 @@ /** Trajectory toolbar: timeline and ledger fold controls. */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { NS } from './locales.ts' import css from './TrajectoryToolbar.module.css' export interface TrajectoryToolbarProps { @@ -30,6 +32,8 @@ export interface TrajectoryToolbarProps { onExport: () => void /** Export failure message, shown while set; null while idle or successful. */ exportError: string | null + /** Translate a toolbar dictionary key. */ + t: TranslateNS } /** @@ -51,17 +55,18 @@ export function TrajectoryToolbar({ exporting, onExport, exportError, + t, }: TrajectoryToolbarProps) { return ( -
+
@@ -134,8 +139,8 @@ export function TrajectoryToolbar({ { onSearchQueryChange(event.currentTarget.value) }} /> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 9d182b379b..2046a9813c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, SessionHistoryFace, SnapshotStore, @@ -187,8 +187,8 @@ function mergeSearchMatches( export function TrajectoryView({ useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog, - inspect, onInspectDone, -}: ConvViewProps & InjectFace) { + inspect, onInspectDone, t, +}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) @@ -563,6 +563,7 @@ export function TrajectoryView({ exporting={exporting} onExport={onExport} exportError={exportError} + t={t} /> {exportError !== null && (
diff --git a/packages/client/ui-trajectory/src/client/export-log.ts b/packages/client/ui-trajectory/src/client/export-log.ts index 4a5ccd8bd1..ba3d1ca8ff 100644 --- a/packages/client/ui-trajectory/src/client/export-log.ts +++ b/packages/client/ui-trajectory/src/client/export-log.ts @@ -7,11 +7,13 @@ /** * Collapse an untrusted session id into one safe path/filename segment. + * Distinct ids may collapse onto one segment (impossible for the host-minted + * UUIDs, so no uniqueness suffix is kept). * @param id - the raw session id. * @returns a filesystem-safe single segment. */ function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9._-]/g, '_') + return id.replace(/[^A-Za-z0-9_-]/g, '_') } /** @@ -25,16 +27,16 @@ export function sessionLogZipFilename(sessionId: string): string { } /** - * Trigger a browser download of raw bytes. - * @param bytes - the file content. + * Trigger a browser download of a blob response. + * @param blob - the response body to save (passed straight through, no copy). * @param filename - the download filename. - * @param type - the MIME type. */ -export function downloadBytes(bytes: Uint8Array, filename: string, type: string): void { - const url = URL.createObjectURL(new Blob([bytes], { type })) +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() - URL.revokeObjectURL(url) + // Revoke one tick later: some browsers read the blob URL after click(). + setTimeout(() => URL.revokeObjectURL(url), 0) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index adfb20fb02..a325f31410 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' import { en, NS, zh } from './locales.ts' -import { downloadBytes, sessionLogZipFilename } from './export-log.ts' +import { downloadBlob, sessionLogZipFilename } from './export-log.ts' /** Required services: the conversation view slot, the independent history source, and the locale service. */ export const inject = ['slots', 'sessionHistory', 'locale'] @@ -33,6 +33,7 @@ export function apply(ctx: Context): void { name: 'conversation.view', id: 'trajectory', order: 10, + locale: NS, label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { const history = ctx.sessionHistory.source(sessionId) @@ -44,7 +45,11 @@ export function apply(ctx: Context): void { exportLog: async () => { // The host streams the ZIP (root + descendant artifacts verbatim) // from GET /api/session.export; the browser downloads the response. - const url = new URL('/api/session.export', window.location.origin) + // A null origin (no-location Node contexts) falls back like the + // carrier's resolveBase so the URL stays valid. + const loc = (globalThis as { location?: { origin?: string } }).location + const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal' + const url = new URL('/api/session.export', origin) url.searchParams.set('sessionId', sessionId) url.searchParams.set('includeDescendants', 'true') const response = await fetch(url) @@ -52,12 +57,7 @@ export function apply(ctx: Context): void { const detail = await response.text().catch(() => '') throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - const blob = await response.blob() - downloadBytes( - new Uint8Array(await blob.arrayBuffer()), - sessionLogZipFilename(sessionId), - 'application/zip', - ) + downloadBlob(await response.blob(), sessionLogZipFilename(sessionId)) }, } }, diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index f5660dfddf..aba527dd2b 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -1,14 +1,32 @@ -/** `trajectory` namespace dictionaries (the view tab label). */ +/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ /** Dictionary namespace owned by this plugin. */ export const NS = 'trajectory' /** The trajectory dictionary key set (the source of truth for both locales). */ -export type TrajectoryKey = 'view.trajectory' +export type TrajectoryKey = + | 'view.trajectory' + | 'toolbar.aria' + | 'toolbar.duration' + | 'toolbar.useActualDuration' + | 'toolbar.useEqualWidth' + | 'toolbar.actualTime' + | 'toolbar.turns' + | 'toolbar.expandTurns' + | 'toolbar.collapseTurns' + | 'toolbar.calls' + | 'toolbar.expandCalls' + | 'toolbar.collapseCalls' + | 'toolbar.export' + | 'toolbar.exportAria' + | 'toolbar.exporting' + | 'toolbar.exportTitle' + | 'toolbar.search' + | 'toolbar.searchPlaceholder' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The trajectory view tab label. */ + /** The trajectory view tab label and toolbar strings. */ 'trajectory': TrajectoryKey } } @@ -16,9 +34,43 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh: Record = { 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': '时长', + 'toolbar.useActualDuration': '使用实际时长', + 'toolbar.useEqualWidth': '使用等宽时长', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': '轮次', + 'toolbar.expandTurns': '展开轮次', + 'toolbar.collapseTurns': '折叠轮次', + 'toolbar.calls': '调用', + 'toolbar.expandCalls': '展开调用', + 'toolbar.collapseCalls': '折叠调用', + 'toolbar.export': '导出', + 'toolbar.exportAria': '导出会话日志', + 'toolbar.exporting': '导出中…', + 'toolbar.exportTitle': '导出会话日志(ZIP,含子代理)', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', } /** English dictionary. */ export const en: Record = { 'view.trajectory': 'Trajectory', + 'toolbar.aria': 'Trajectory toolbar', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': 'Actual time', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': 'Search trajectory', + 'toolbar.searchPlaceholder': 'Search', } diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts index e693ded89b..ba7f739573 100644 --- a/packages/client/ui-trajectory/tests/export-log.spec.ts +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -10,11 +10,15 @@ import { sessionLogZipFilename } from '../src/client/export-log.ts' describe('sessionLogZipFilename', () => { it('keeps safe session ids verbatim', () => { - expect(sessionLogZipFilename('session-abc_1.2')).toBe('dsh-session-session-abc_1.2.zip') + expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip') }) it('neutralizes unsafe id characters that could shape the filename', () => { - expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip') + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip') expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') }) + + it('strips dots so a dot-only id cannot shape a dot segment', () => { + expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') + }) }) diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx index b3e2a4c014..820af51ec7 100644 --- a/packages/client/ui-trajectory/tests/toolbar.spec.tsx +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -3,7 +3,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' + +/** Test translator pinned to the Simplified Chinese dictionary. */ +const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key afterEach(() => { cleanup() @@ -25,6 +30,7 @@ function baseProps(overrides: Partial = {}): TrajectoryT exporting: false, onExport: vi.fn(), exportError: null, + t: zhT, ...overrides, } } diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 9c52417b61..e76cc0f41b 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -28,6 +28,8 @@ import { import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -142,14 +144,18 @@ function emptyWorkspaces() { } /** Standalone view props: the session-scope standard kit the outlet would bake. */ -function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { +function standaloneProps( + nodes: ConversationSnapshot['nodes'], +): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, - } as unknown as ConvViewProps + // The locale seat the outlet would inject for the declared namespace. + t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, + } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } } /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ @@ -230,6 +236,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES exportLog: trajectory.exportLog, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), + t: (key: TrajectoryKey) => zh[key], } })() : injected @@ -324,12 +331,12 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) + fireEvent.click(screen.getByRole('button', { name: '折叠轮次' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) + fireEvent.click(screen.getByRole('button', { name: '展开轮次' })) expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() await vi.waitFor(() => { @@ -540,13 +547,13 @@ describe('tab switching in ConversationRoot', () => { const b = await bench(historySnapshot([])) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByRole('button', { - name: 'Collapse turns', + name: '折叠轮次', }).disabled).toBe(false) expect(screen.getByRole('button', { - name: 'Collapse calls', + name: '折叠调用', }).disabled).toBe(false) expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() @@ -1090,7 +1097,7 @@ describe('timeline projection', () => { ...standaloneExport(), }, )) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) @@ -1123,7 +1130,6 @@ describe('session log export', () => { expect(fetchMock).toHaveBeenCalledOnce() }) // The blob download lands a few microtasks after the fetch settles. - // The blob download lands a few microtasks after the fetch settles. await vi.waitFor(() => { expect(createObjectURL).toHaveBeenCalled() }) @@ -1159,7 +1165,7 @@ describe('TrajectoryView branches', () => { setActualDuration={(value) => { firstDuration.set(value) }} />, ) - const duration = screen.getByRole('button', { name: 'Use actual duration' }) + const duration = screen.getByRole('button', { name: '使用实际时长' }) expect(duration.getAttribute('aria-pressed')).toBe('false') fireEvent.click(duration) @@ -1175,7 +1181,7 @@ describe('TrajectoryView branches', () => { setActualDuration={(value) => { restoredDuration.set(value) }} />, ) - expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) + expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed')) .toBe('true') }) From 2a34ca51a3e0809d758b1b30a8d090855703d38c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:32 +0800 Subject: [PATCH 08/23] test(web): assembled session-log download e2e and refreshed golden The navigation-panes replay scenario clicks the trajectory toolbar export button and asserts the real host-streamed download: filename, a single session.jsonl entry, and byte-verbatim seed content. The toolbar golden refreshes for the localized export button. --- apps/web/package.json | 3 ++- apps/web/tests/navigation-panes.e2e.ts | 18 ++++++++++++++++++ .../navigation-panes/trajectory.expected.md | 2 +- pnpm-lock.yaml | 3 +++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index c58e5b9682..66328783af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "playwright": "^1.49.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "fflate": "^0.8.2" } } diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index b96a1fa393..c8b6455296 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' +import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -274,6 +275,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await page.getByRole('button', { name: 'Export session log' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/) + // The real host streamed the ZIP; its root entry is the persisted log + // text verbatim (the assembled seam: real route, real persistence read). + const files = unzipSync(await readFile(await download.path())) + expect(Object.keys(files)).toEqual(['session.jsonl']) + const content = strFromU8(files['session.jsonl'] as Uint8Array) + expect(content.split('\n')[0]).toContain(SEED_ID) + expect(content).toContain('FIRST_DONE') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 8e6fa01d68..3476255bab 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,7 +2,7 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls - - button "导出会话日志": 导出 + - button "Export session log": Export - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a69b80cee1..da0183895a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + fflate: + specifier: ^0.8.2 + version: 0.8.3 playwright: specifier: ^1.49.0 version: 1.61.1 From fbd09a70f3fb20fe1b2acc771ed4335fe921abda Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:43 +0800 Subject: [PATCH 09/23] docs: document SessionRawArtifact and fix backpressure claims The persistence subsystem page gains the SessionRawArtifact section (with a type-equiv manifest entry and zh counterpart), and the README and Agent Note describe the drain-based backpressure instead of claiming the archive never accumulates. --- ...2026-08-10-web-session-log-export.i18n.yaml | 4 ++-- .../2026-08-10-web-session-log-export.md | 4 ++-- .../2026-08-10-web-session-log-export.zh.md | 4 ++-- docs/subsystems/persistence.i18n.yaml | 4 ++-- docs/subsystems/persistence.md | 18 +++++++++++++++++- docs/subsystems/persistence.zh.md | 18 +++++++++++++++++- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../tool-cordis/src/api-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 5 +++++ 11 files changed, 52 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 60c70d5519..0c8a3f2781 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 9d31e4deb640474230dd9b16ec02f2f36dc5ca56 -2026-08-10-web-session-log-export.zh.md: 0a2ba678b0ea56202493507165d94750761ce3d9 +2026-08-10-web-session-log-export.md: 427b6478ac44fb28030aa932630f276de7bb2edc +2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 9d31e4deb6..427b6478ac 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -1,4 +1,4 @@ -# Agent Note: Web session-log export via session.log + ZIP download +# Agent Note: Web session-log export as a host-streamed ZIP download Status: implemented @@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision -- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never materializes the whole archive (at most one descendant's artifact text beyond the preloaded root). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot). - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 0a2ba678b0..63b9804a54 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Web 会话日志导出——session.log RPC + ZIP 下载 +# Agent Note:Web 会话日志导出——宿主流式 ZIP 下载 状态:implemented @@ -10,7 +10,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 -- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进内存(除预载的根外,最多同时持有一条后代的工件文本)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index fa128a80d0..602ec01ec9 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 25541139de02d2bd3ea743fe530e47a628002695 -persistence.zh.md: d76ff93f5564e7612fd7107cd74e3138c03542d8 +persistence.md: 363d28034b58d007d1440afcb8fb9137b0048091 +persistence.zh.md: e208720eae1902ad429fa358c112fe9188f8d8dd diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 25541139de..363d28034b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -118,6 +118,22 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## `SessionRawArtifact` — verbatim stored artifact text + +A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives; backends without a per-session artifact, such as SQLite, inherit the `undefined` default. + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## Preparation and restoration ownership `SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. Persistence inspection exposes only `SessionInspection`, an immutable logical view borrowed from the same prepared Session. @@ -250,7 +266,7 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ -readRaw(_id: SessionId, signal?: AbortSignal): Promise +async readRaw(_id: SessionId, signal?: AbortSignal): Promise /** * Register a new session's metadata. A backend MAY defer the physical write diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index d76ff93f55..e208720eae 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -118,6 +118,22 @@ interface CreateSessionOptions { 因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +## `SessionRawArtifact`——逐字存储工件文本 + +后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留;没有每会话工件的后端(如 SQLite)继承 `undefined` 默认。 + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## 准备与恢复所有权 `SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的新鲜持久化对象图。恢复分支会直接验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 @@ -250,7 +266,7 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ -readRaw(_id: SessionId, signal?: AbortSignal): Promise +async readRaw(_id: SessionId, signal?: AbortSignal): Promise /** * Register a new session's metadata. A backend MAY defer the physical write diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 6a7abf8bdc..4a6976a6d6 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 452e157ed93ca4ace07a8c958bad04b15d598b31 -README.zh.md: f0509276ef690874e788a3a1e312d35ec37034f0 +README.md: 7d48fd9eaa5ce65a9b5ad22942fe82c2292bd710 +README.zh.md: 245ee6ed62231c9e4381405a8dd3c04428307061 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 452e157ed9..7d48fd9eaa 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never materializes the whole archive. It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f0509276ef..245ee6ed62 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进内存。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 5f4d9a124b..6f9abb64da 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -689,7 +689,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, { - signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + signature: 'async readRaw(_id: SessionId, signal?: AbortSignal): Promise', jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', }, { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..ef44631f07 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -440,6 +440,11 @@ "symbol": "SessionLocation", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionRawArtifact", + "source": "packages/session/session-persistence/src/index.ts" + }, { "doc": "docs/subsystems/session-query.md", "symbol": "SessionEventSurface", From fa2dce28ce2c6ea97465ba465bc4c471f3745d21 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 20:15:31 +0800 Subject: [PATCH 10/23] test(web): adapt ui-trajectory benches to the merged locale settings scope The locale plugin now derives its durable preference from a settings scope bound to the connection service, so the trajectory benches provide a connection handle before loading it. --- packages/client/ui-trajectory/tests/client-bundle.spec.ts | 4 +++- packages/client/ui-trajectory/tests/views.spec.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index ebe08ad920..f5a1c08960 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -75,8 +75,10 @@ describe('tsdown client artifact', () => { }, (_p: { renderSlot?: unknown }) => null) // The plugin reads sessionHistory for its per-session history source; // slot availability is tracked by slots.inject, and the locale plugin - // backs the locale-aware view tab label. + // backs the locale-aware view tab label (its settings scope needs a + // connection handle). ctx.provide('sessionHistory', {}) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index e76cc0f41b..81fc9df818 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -181,7 +181,9 @@ async function bench(snapshot = historySnapshot(NODES)) { slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) ctx.provide('sessionHistory', { source: () => history }) - // The locale plugin backs the locale-aware view tab label ('locale' in inject). + // The locale plugin backs the locale-aware view tab label ('locale' in + // inject); its settings scope needs a connection handle. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() From 1cfbafab6a09c6ded171dd5d384a5a8c641bafc4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 22:36:06 +0800 Subject: [PATCH 11/23] fix(preset): keep the token meter host-plane and name unjoined agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the agent plane behind presets left two readers on the wrong side of the host/agent line. `dsh-token-meter` was disabled on the host and mounted inside each preset's `compaction` realm, but its three projection units register into the process-wide `sessionProjections` table. A unit registered from one preset answers for every session, so whether a `minimal` session showed a context meter depended on whether some other session had mounted `standard` since boot, and a process that only ever ran `minimal` showed none. The meter takes no configuration, keys every fold by Session, and registers no tool or prompt section, so it returns to the host composition and leaves the presets' `isolate` map; the realm and `compact-basic` stay, because what a preset chooses is whether its agent compacts, not whether its tokens are counted. Nothing named an agent that joined no preset. The join is a scope-parent link, and without it the tools, prompt-section, and skill views resolve the empty global layer: the agent publishes, the turn runs, and the model receives nothing. `AgentPresets` now logs one warning per such agent while a roster is configured, and the invariant companion fails outright — at `system-prompt/assemble` rather than at publication, because an unjoined agent is legal until it addresses a model and `recompose` binds exactly such an agent. The warning stays advisory: a synchronous `agent/created` throw vetoes publication, and the ACP bridge, SDK server, and headless bundle all create an unjoined agent today. Three limits are recorded rather than fixed: projection key presence is not a per-session capability signal, a superseded standing generation is never reclaimed, and a `cordis_mount` temporary plugin belongs to the composition rather than the session that mounted it. Fixes #2203 --- ...st-plane-ownership-after-presets.i18n.yaml | 6 +++ ...8-10-host-plane-ownership-after-presets.md | 45 +++++++++++++++++++ ...0-host-plane-ownership-after-presets.zh.md | 45 +++++++++++++++++++ .../agent-presets/code/agent.cordis.yml | 11 +++-- .../agent-presets/cordis/agent.cordis.yml | 11 +++-- .../agent-presets/minimal/agent.cordis.yml | 4 +- .../agent-presets/standard/agent.cordis.yml | 11 +++-- apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.e2e.ts | 28 ++++++++++++ apps/web/tests/seeded-history.e2e.ts | 21 +++------ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 34 +++++++------- docs/module-graph.zh.md | 34 +++++++------- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 10 +++-- docs/subsystems/tools.zh.md | 10 +++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- packages/acp/acp/src/index.ts | 6 +++ packages/bundle/headless/src/index.ts | 7 +++ packages/bundle/web-app/cordis.patch.yml | 13 +++++- packages/core/agent-tool-mode/src/index.ts | 10 +++-- packages/core/tools/src/index.ts | 18 ++++---- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 3 +- packages/preset/agent-presets/README.zh.md | 3 +- packages/preset/agent-presets/package.json | 2 + packages/preset/agent-presets/src/index.ts | 35 ++++++++++++++- .../preset/agent-presets/src/invariant.ts | 33 +++++++++++++- .../agent-presets/tests/invariant.spec.ts | 24 ++++++++++ .../preset/agent-presets/tests/mount.spec.ts | 16 +++++++ packages/preset/agent-presets/tsconfig.json | 6 +++ packages/scaffold/server/src/server.ts | 6 +++ .../tool-cordis/README.i18n.yaml | 4 +- .../self-modification/tool-cordis/README.md | 1 + .../tool-cordis/README.zh.md | 1 + .../tool-cordis/src/api-catalog.ts | 2 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 1 + .../session/session-projection/README.zh.md | 1 + pnpm-lock.yaml | 3 ++ 49 files changed, 398 insertions(+), 114 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml new file mode 100644 index 0000000000..da6e264f1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +2026-08-10-host-plane-ownership-after-presets.md: 5b0a340e875005182a0e6cd0f880b34b14258fb2 +2026-08-10-host-plane-ownership-after-presets.zh.md: 4b1e04f924e656b0f6ad4d070a3b77ce0189c608 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md new file mode 100644 index 0000000000..5b0a340e87 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -0,0 +1,45 @@ +# Agent Note: What stays host-plane once presets own the agent plane + +Status: implemented + +English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md) + +## Problem + +[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). + +Two more readers were still on the wrong side of that line. + +`dsh-token-meter` was disabled on the host and mounted inside each preset's `compaction` realm. It takes no configuration, keys every fold by `Session`, and registers no tool or prompt section — but it owns the `tokenUsage`, `contextPressure`, and `contextBreakdown` projection units, and `sessionProjections` is a process-wide table with no scope layering. A unit registered from inside one preset therefore answers for every session: whether a `minimal` session showed a context meter depended on whether some *other* session had mounted `standard` since boot, and a process that only ever ran `minimal` showed none at all. + +Nothing named an agent that joined no preset. The join is a scope-parent link; without it the `tools`, `system-prompt`, and `skill` views resolve the empty global layer and the model receives nothing — no error, no empty catalog, just an agent that cannot act. That is how delegated subagents ran for as long as presets existed, and the same hole is open at every entry point that predates them. + +## Decision + +**The meter is host-plane.** `dsh-token-meter` returns to the host composition and leaves the presets' `isolate` map, so `compact-basic` and `tool-result-prune` resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion `tasks` and `goals` are already read by, applied to a Service whose *projection* reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process. + +**An unjoined agent is named twice, at two different points.** `AgentPresets` logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at `system-prompt/assemble`, not at publication, because an unjoined agent is legal until it addresses a model: `recompose` binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range. + +Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal ([`dsh-session-projection`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md)). + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. + +`packages/preset/agent-presets/tests/mount.spec.ts` asserts the warning fires exactly once for a bare agent and not at all for a joined one. `tests/invariant.spec.ts` carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass. + +## Alternatives considered + +**Keep the meter in the preset and scope-layer the projection registry.** The precise fix, and much larger: `snapshot`, `checkpoint`, and the eager drive would each need a session→scope resolution that a cold read does not have without the api-proxy's `presenterScopeFor`. Rejected as disproportionate to one Service with no per-preset state at all; the general rule is documented on the registry instead. + +**Veto publication for an unjoined agent.** Loud beats silent, and the registry supports it — a synchronous `agent/created` listener that throws rolls the creation back. Rejected because composing an agent outside the roster is legal: `recompose` documents the bare agent it then binds, and the ACP bridge, the SDK server, and the headless bundle all create one today. A veto would convert a capability gap into an outage. + +**Check the join at `agent/created` in the companion too.** Rejected: publication cannot distinguish a missed join from an agent that will be bound later, so the check would reject a documented path. Prompt assembly can distinguish them. + +**Move `plan-mode` and `tool-todo` off the agent plane for the same projection reason.** Rejected: both are genuinely per-preset capabilities, and their units compute an empty value for a session that never uses them, which clients already read by value (`plan.active`, an empty list). Only a unit whose empty value is indistinguishable from a real one — the meter — forces host ownership. + +## Consequences + +The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. + +The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load `dsh-invariants`, which fences package tests and development hosts, not a shipped one. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md new file mode 100644 index 0000000000..4b1e04f924 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -0,0 +1,45 @@ +# Agent Note: What stays host-plane once presets own the agent plane + +Status: implemented + +[English](2026-08-10-host-plane-ownership-after-presets.md) | 中文 + +## Problem + +[逐会话 agent preset](2026-08-03-per-session-agent-presets.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md))。 + +还有两个读取点仍站在这条线的错误一侧。 + +`dsh-token-meter` 在宿主侧被禁用,改挂进每个 preset 的 `compaction` realm。它不接受任何配置,每次折叠都以 `Session` 建键,也不注册工具或提示段——但它拥有 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 三个投影单元,而 `sessionProjections` 是一张进程级、没有作用域分层的表。因此从某个 preset 内部注册的单元会替所有会话作答:一个 `minimal` 会话是否显示 context meter,取决于本次启动以来有没有**别的**会话挂过 `standard`;而只跑过 `minimal` 的进程根本不显示。 + +没有加入任何 preset 的 agent 也无人指出。加入是一条 scope 父链链接;缺了它,`tools`、`system-prompt` 与 `skill` 的视图都解析到空的全局层,模型什么也收不到——不报错,也没有空目录可看,只是一个无法行动的 agent。被委派的子 agent 在 preset 存在的整段时间里都是这样运行的,而同一个洞在每一个早于 preset 的入口点上都开着。 + +## Decision + +**meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compact-basic` 与 `tool-result-prune` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks` 与 `goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。 + +**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。 + +有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md))。 + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` 在本文件中任何 preset 挂载**之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 + +`packages/preset/agent-presets/tests/mount.spec.ts` 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。`tests/invariant.spec.ts` 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。 + +## Alternatives considered + +**把 meter 留在 preset,改为给投影注册表分层。** 这是更精确的修法,代价也大得多:`snapshot`、`checkpoint` 与主动驱动都需要一次「会话 → 作用域」的解析,而冷读在没有 api-proxy 的 `presenterScopeFor` 时并不具备。相对于一个完全没有 per-preset 状态的 Service,这不成比例,因此改为把通则写在注册表上。 + +**对未加入的 agent 否决发布。** 大声胜过静默,注册表也支持这么做——同步的 `agent/created` 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——`recompose` 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 今天都会创建一个。否决会把能力缺口变成一次故障。 + +**让配套也在 `agent/created` 处检查加入情况。** 否决:发布时分不清漏掉的加入与之后才会被绑定的 agent,因此该检查会拒绝一条已写明的路径。提示词组装分得清。 + +**基于同样的投影理由,把 `plan-mode` 与 `tool-todo` 也搬离 agent 平面。** 否决:两者确实是逐 preset 的能力,且对从不使用它们的会话,其单元算出的就是空值,而客户端本来就按值读取(`plan.active`、空列表)。只有空值与真实值无法区分的单元——meter——才被迫归宿主所有。 + +## Consequences + +context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 + +那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 5e273bb955..185254224c 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -134,17 +134,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 1bc3d3bcf0..a32fd3dbe0 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -115,17 +115,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 6ae88b9339..2a682375d7 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -7,7 +7,9 @@ # from a shared default. # # The host composition is unchanged: this agent still runs inside the same -# sandbox, approval, persistence, and model routing as any other session. +# sandbox, approval, persistence, and model routing as any other session — and +# the same token meter, so the browser's context meter reads this session like +# any other. What this preset drops is auto-compaction, not the accounting. - id: persona name: '@deepseek-ai/dsh-persona' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 6442b12306..c5b9518400 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -127,17 +127,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/apps/cli/package.json b/apps/cli/package.json index 03bd4c3db9..1668ee8bcc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 004de203c8..70bbe66cc6 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -15,6 +15,9 @@ import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subage import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' +// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`. +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-token-meter' const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) @@ -113,6 +116,31 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx)).toEqual([]) }) + it('keeps the token meter and its context-meter projections on the host plane', async () => { + // Read before any preset in this file mounts, which is what makes this an + // ownership assertion rather than a mount-order coincidence: a preset-side + // meter sits behind an `isolate` realm and is invisible to `ctx.get`. + // + // The projection registry is process-wide rather than scope-layered, so a + // preset-side meter would also make the browser's context meter appear for + // a `minimal` session the moment some OTHER session mounted a preset that + // carries one, and vanish entirely in a process that only ever ran + // `minimal`. Host ownership is what makes the meter a per-session fact. + expect(ctx.get('tokenMeter')).toBeDefined() + const projections = ctx.get('sessionProjections') + if (projections === undefined) throw new Error('the Web composition must compose a projection registry') + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-meter'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + expect(Object.keys(projections.snapshot(handle.agent.session).values).sort()) + .toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage'])) + } finally { + await handle.dispose() + } + }) + it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a112933f9c..82a8fcf58a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' @@ -195,21 +194,11 @@ describe('web e2e: seeded history renders through cold resume', () => { if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - // The meter belongs to an agent's preset, not to the process — token - // accounting is per session. It is used here as a pure pricing function - // over fixture content, so a throwaway composition is enough to reach one. - const priced = await scaffold.ctx.agents.create({ - sessionId: SessionId('seeded-history-pricing'), - setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined), - }) - let realizedWithCompaction: string - try { - const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter') - if (meter === undefined) throw new Error('seeded-history requires the composed token meter') - realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) - } finally { - await priced.dispose() - } + // The meter is host-plane — it takes no configuration and keys every + // fold by Session — so pricing fixture content needs no agent at all. + const meter = scaffold.ctx.get('tokenMeter') + if (meter === undefined) throw new Error('seeded-history requires the host token meter') + const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 889b2e24e7..400911a03a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 471680f92dc44f3dd4e98ba9e946525ec79f25b0 -config-catalog.zh.md: bf78766799b02a1f6f21f935723abace108bc306 +config-catalog.md: d2db35d5ad50025975e0cbfe2275b752151c08c4 +config-catalog.zh.md: 056309751a677027c289ad14af27a6e88999025f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 471680f92d..d2db35d5ad 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -259,7 +259,7 @@ export interface Config { Depends on: [`ToolPresentationMode`](subsystems/tools.md) -Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +Source: [`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts) ## `@deepseek-ai/dsh-attachment-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bf78766799..056309751a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -261,7 +261,7 @@ export interface Config { 依赖:[`ToolPresentationMode`](subsystems/tools.md) -来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +来源:[`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts) ## `@deepseek-ai/dsh-attachment-local` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 268fa1c8d0..f351e7c078 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: d2e5cd66c2406cf84320fc1c0537d580214dd93d -event-producer-consumer.zh.md: 12ac194d68ed6121cc0b2c5e9bf2c7f77944f57b +event-producer-consumer.md: d27dfcc7b05f590d7117c650b79841ba2e00dbfe +event-producer-consumer.zh.md: e49e806b3a7dded440e6ffcfbe23d9548c803374 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d2e5cd66c2..d27dfcc7b0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | @@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 12ac194d68..e49e806b3a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | @@ -43,7 +43,7 @@ | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 77fcfca867..34a297395b 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: b596cb564943e321ff42cd59855bd7dc10981319 -module-graph.zh.md: b64f30c2df1eedb1a1fa106b79710558b765fdde +module-graph.md: c3e9334f5d993e7cb1d5d032a1df7fca28e0928e +module-graph.zh.md: 89b7f921fc7f54226f2435c7d0afc08c61fe9013 diff --git a/docs/module-graph.md b/docs/module-graph.md index b596cb5649..c3e9334f5d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -515,12 +515,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_client_ui_slots pkg_host_directory_picker_native --> pkg_client_ui_workspace pkg_host_directory_picker_native --> pkg_invariants - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -572,8 +566,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -593,6 +585,14 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -702,11 +702,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants @@ -718,6 +713,8 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_user_id + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -889,6 +886,11 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1324,7 +1326,6 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1338,11 +1339,11 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1369,10 +1370,10 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1401,6 +1402,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b64f30c2df..89b7f921fc 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -517,12 +517,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_client_ui_slots pkg_host_directory_picker_native --> pkg_client_ui_workspace pkg_host_directory_picker_native --> pkg_invariants - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -574,8 +568,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -595,6 +587,14 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -704,11 +704,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants @@ -720,6 +715,8 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_user_id + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -891,6 +888,11 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1326,7 +1328,6 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1340,11 +1341,11 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1371,10 +1372,10 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1403,6 +1404,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a7d26cee1b..0123946525 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: ad00c4da7d77b0e1ab4728173b202ebc17fb56a0 -core.zh.md: 9c606023c85369643e7148f829526b1f75ea3631 +core.md: 96655026f5affda6fed080496d975e2366f0356f +core.zh.md: e2cde8845ddf6b78f64d062fd8860c0c88b7ce11 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ad00c4da7d..96655026f5 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9c606023c8..e2cde8845d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index fbf617f20a..90ee0c989f 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493 -tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7 +tools.md: 4e56d420f9ba9541725e41e4da87846654206119 +tools.zh.md: f6eee0f0f9b3c549679cc4437755c749caf68c9c diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 6ff2d967c5..4e56d420f9 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ```ts cordis-catalog /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 82ade5d8d4..f6eee0f0f9 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ```ts cordis-catalog /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6fa938c18e..f9828f9f13 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present the calling scope's tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset's standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents' models see.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index ce00fb105a..437a0926ed 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -252,6 +252,12 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() validateSessionParams(params) const sessionId = SessionId(randomUUID()) + // No preset composition: the ACP bundle keeps the model-facing rows in + // the host plane, so this agent reads them from the global registry + // layer. A deployment that configures `dsh-agent-presets` has to join + // one here first — registration visibility inherits only along the + // `dsh-scope` parent chain, and an agent created without that link + // reads an empty global layer and reaches the model with no tools. const handle = await agents.create({ sessionId, meta: { cwd: params.cwd }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 2ea68c441b..ad3a41c203 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -106,6 +106,13 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { if (agents === undefined || defaultModel === undefined || sessions === undefined) return const selection = defaultModel.currentSelection() + // This bundle composes no preset roster, so the model-facing rows sit in the + // host plane and the agent reads them from the global registry layer. A + // deployment that DOES configure `dsh-agent-presets` has to join one here + // first: registration visibility inherits only along the `dsh-scope` parent + // chain, and `AgentPresets.mount()` is the only thing that links an agent to + // it, so an agent created without that link reads an empty global layer and + // reaches the model with no tools at all. const { agent } = await agents.create({ sessionId: SessionId(`session-${randomUUID()}`), meta: { cwd: process.cwd() }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 3e88c4d6ca..c3ec7844b9 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -270,8 +270,17 @@ - id: plan-mode disabled: true -- id: token-meter - disabled: true +# The token METER stays on the host plane; only the compaction backend that +# reads it moves. It takes no configuration at all, keys every fold by Session, +# and registers no tool or prompt section, so one host instance serves every +# preset exactly as before. It also owns the `tokenUsage`/`contextPressure`/ +# `contextBreakdown` projection units, and the projection registry is +# process-wide rather than scope-layered: registered from inside a preset, the +# browser's context meter would appear for a session whose own composition +# mounts no meter as soon as some OTHER session mounted one, and disappear +# entirely in a process that only ever ran `minimal`. That is the same criterion +# `tasks` and `goals` are read by — a Service the host and every preset can see +# belongs to the plane both can see. - id: compact-basic disabled: true diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts index d2f1e8fd49..75ff10b29a 100644 --- a/packages/core/agent-tool-mode/src/index.ts +++ b/packages/core/agent-tool-mode/src/index.ts @@ -5,8 +5,10 @@ * The tool registry itself stays on the host plane — the agent loop's * scheduler, the API proxy's presenters, and every tool plugin are all its * consumers, so it cannot move into a preset. What a preset CAN own is the - * presentation: `ctx.tools.presentAs()` declares it for the mounting agent - * alone, so a Code Mode agent runs beside native ones in one process. + * presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE, + * which is the preset's standing mount, so the declaration covers every agent + * joined to that preset and a Code Mode preset runs beside native ones in one + * process. One row per composition, not one per session. * * A code mode needs a TypeScript code runtime, which is a host-plane service * ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)). @@ -50,8 +52,8 @@ export const Config: z = z.object({ }) /** - * Declare this agent's tool presentation. - * @param ctx - the mounting agent's scope context. + * Declare the tool presentation for every agent this composition covers. + * @param ctx - the mounting composition's scope context (a preset's standing scope). * @param config - the selected presentation. */ export function apply(ctx: Context, config: Config): void { diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 86c69d9307..547eb19f56 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -786,7 +786,7 @@ export class ToolRegistry extends Service { scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }, ) - /** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */ + /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */ private readonly defaultMode: ToolPresentationMode private readonly maxParallelSubCalls: number /** @@ -811,7 +811,7 @@ export class ToolRegistry extends Service { /** * The generated-SDK prompt section, registered globally by a code-mode - * deployment and per agent by {@link presentAs}. + * deployment and per scope by {@link presentAs}. * * The body regenerates from the CALLING scope, and renders empty for an * agent presenting natively — an agent that opted out under a code-mode @@ -880,12 +880,14 @@ export class ToolRegistry extends Service { } /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void { @@ -898,14 +900,14 @@ export class ToolRegistry extends Service { ctx, (layer) => { if (layer.mode !== undefined) { - throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`) + throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`) } layer.mode = mode return () => { layer.mode = undefined } }, { label: 'tools.presentAs()' }, ) - // The SDK section is per agent for the same reason the mode is. Under a + // The SDK section is per scope for the same reason the mode is. Under a // deployment that already defaults to a code mode this shadows the // global registration with an identical body, which costs nothing and // keeps one rule instead of a case analysis. diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index ecd2a5a6c0..5d1f4fff96 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 632fc7828313a932512cb59a924050005067a008 -README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899 +README.md: 2c9a16081e93895e40a60378376d0db06e271913 +README.zh.md: 2598b35f5494f6c56dfc9f1ba08a6784a5087cb6 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 632fc78283..2c9a16081e 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -133,7 +133,8 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. -- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions). +- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. +- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. - **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start. - **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back. - **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 41dfab1af8..2598b35f54 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -133,7 +133,8 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 -- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。 +- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 +- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 - **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 - **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。 - **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index c012dc3eda..2da791cffe 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -27,12 +27,14 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 1dde902234..1ed8fdbdc8 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -24,7 +24,9 @@ import { stat } from 'node:fs/promises' import { Context, Service } from 'cordis' import z from 'schemastery' -import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeChainOf, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +// Type-only: resolves the `agent/created` lifecycle event this service watches. +import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' @@ -130,6 +132,31 @@ export class AgentPresets extends Service { this.settingsService = undefined }, 'agentPresets.settings()') }) + + // An agent joins a preset by having its scope key parented to a standing + // mount, and `mount`/`composeFrom` are the only things in the runtime that + // install that link. An agent that never joined keeps a chain of length + // one, so its `tools`, `system-prompt`, and `skill` views resolve against + // the EMPTY global layer and the model simply has nothing — no error, no + // empty catalog to notice, just an agent that cannot act. + // + // Advisory rather than fatal, and deliberately not the same observation the + // invariant companion makes. A synchronous `agent/created` listener that + // throws VETOES publication, and this service must not: composing an agent + // outside the roster is legal (`recompose` documents the bare agent it then + // binds, and entry points that predate presets still create one), so + // vetoing would turn a capability gap into an outage. The companion fails + // loud instead, at the later point where the empty world reaches a model. + ctx.on('agent/created', ({ agent }) => { + if (this.config.roots.length === 0) return + const key = scopeOf(agent.ctx) + if (key !== undefined && scopeChainOf(key).length > 1) return + ctx.logger.warn( + `agent "${agent.id}" was published without joining an agent preset; ` + + 'its tools, prompt sections, and skill catalog resolve against the empty global layer ' + + '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)', + ) + }) } /** @@ -440,6 +467,12 @@ export class AgentPresets extends Service { // disappearing, and failing the session over a stat would not. const current = await compositionStamp(preset.path) if (current === undefined || sameStamp(mounted.stamp, current)) return mounted + // TODO: reclaim the superseded generation once the last agent joined to + // it is gone. The subtree is not inert — `dsh-skill-local` watches its + // roots — and the settings-page authoring flow turns "a composition + // changed" into a per-save event. This needs a joined-agent count on + // StandingMount, incremented in `mount`/`composeFrom`/`recompose` and + // decremented when the agent's scope key dies. // Guarded delete: a caller that raced this one may have already started // the next generation, and dropping THAT pointer would fork a third. if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id) diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index 7a08eab7f1..dd80320a5a 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -5,6 +5,9 @@ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { scopeChainOf } from '@deepseek-ai/dsh-scope' +// Type-only: resolves the `system-prompt/assemble` waterfall this companion joins. +import type {} from '@deepseek-ai/dsh-system-prompt' // Imported through the package name, not `./mount.ts`: a module shared between // the two build entry points becomes a third chunk that the published `files` // list does not carry, which `verify-built-package-invariants` rejects. @@ -18,9 +21,10 @@ export const name = 'agent-presets-invariant' export const inject = ['invariants'] /** - * Assert that no installed preset composition reaches the root service realm. + * Assert that no installed preset composition reaches the root service realm, + * and that a deployment configuring a roster composes every agent from it. * - * `mountPreset` proves this once, when the subtree settles. A row that + * `mountPreset` proves the first once, when the subtree settles. A row that * publishes later — from a timer, or an asynchronous continuation after its * plugin returned — would escape that one-shot audit, so re-check every live * mount whenever a service registration changes. @@ -37,6 +41,31 @@ const install: InvariantInstaller = (ctx, fail) => { ) } }, { global: true }) + + // The join is a scope-parent link, and `AgentPresets.mount()` is the only + // thing in the runtime that installs one. An agent minted without it keeps a + // chain of length one, so its `tools`, `system-prompt`, and `skill` views + // fall back to the empty global layer and the model receives nothing. + // + // Checked at ASSEMBLY, not at publication: an unjoined agent is legal until + // it addresses a model — `recompose` binds a bare agent as its first link, + // and that agent is unjoined for its whole life up to the switch. Assembling + // a prompt is the point where the empty world stops being a state and + // becomes what the model sees, and it is the only caller that supplies an + // agent scope, so a host assembly (no scope) and a standing mount are both + // correctly out of range. + ctx.on('system-prompt/assemble', (_assembly, context, next) => { + const presets = ctx.get('agentPresets') + const scope = context.scope + if (presets !== undefined && presets.config.roots.length > 0 + && scope !== undefined && scopeChainOf(scope).length === 1) { + fail( + 'an agent addressed a model without joining any agent preset while a roster is composed; ' + + 'its tools, prompt sections, and skill catalog resolve against the empty global layer', + ) + } + return next() + }) } /** diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 17d89813c3..5577962a1d 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -84,4 +84,28 @@ describe('agent-presets invariants', () => { setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), })).resolves.toBeDefined() }) + + it('rejects an agent that addresses a model without joining any preset', async () => { + const ctx = await harness() + // The delegation shape: an agent composed outside the roster keeps a + // scope chain of length one, so every registry view it reads is the empty + // global layer. Publication alone stays legal — `recompose` binds exactly + // such an agent — so nothing fires until that empty world reaches a prompt. + const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') }) + + await expect(ctx.systemPrompt.assemble({ scope: handle.agent })) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('admits a joined agent and a host assembly that names no scope', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + sessionId: SessionId('inv-joined'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined() + // A scopeless assembly belongs to no agent, so it cannot be an unjoined one. + await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined() + }) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index afe297a406..8266a4850b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -499,6 +499,22 @@ describe('replacing a composition', () => { expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) }) + it('names an agent that was published without joining any preset', async () => { + const ctx = await harness() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + + await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') }) + // Advisory, not fatal: a synchronous `agent/created` throw would veto + // publication, and creating an agent outside the roster stays legal. + expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1) + expect(warnings.at(-1)).toMatch(/without joining an agent preset/) + + warnings.length = 0 + await agentOn(ctx, 'sess-joined-quiet', 'minimal') + expect(warnings).toEqual([]) + }) + it('composes an agent that had nothing installed', async () => { // An agent created without a preset has no binding to re-link, so the // switch is its first bind — exactly a mount — and once bound only the diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 47d5577207..a12c5a0149 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -18,12 +18,18 @@ { "path": "../../../vendor/include" }, + { + "path": "../../core/agent" + }, { "path": "../../core/scope" }, { "path": "../../core/session" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../settings/settings" }, diff --git a/packages/scaffold/server/src/server.ts b/packages/scaffold/server/src/server.ts index e6c22d7ff2..cb75881303 100644 --- a/packages/scaffold/server/src/server.ts +++ b/packages/scaffold/server/src/server.ts @@ -216,6 +216,12 @@ export class HarnessSdkServer { } private async createSession(sessionId: string): Promise { + // No preset composition: the SDK server's compositions keep the + // model-facing rows in the host plane, so this agent reads them from the + // global registry layer. A deployment that configures `dsh-agent-presets` + // has to join one here first — registration visibility inherits only along + // the `dsh-scope` parent chain, and an agent created without that link + // reads an empty global layer and reaches the model with no tools. const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/self-modification/tool-cordis/README.i18n.yaml b/packages/self-modification/tool-cordis/README.i18n.yaml index ef1f441711..0062df8349 100644 --- a/packages/self-modification/tool-cordis/README.i18n.yaml +++ b/packages/self-modification/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/tool-cordis/README.md -README.md: f2a65043a1d2f74553e98caf59ed3d38b5a70b7c -README.zh.md: 66742094992d219ccfbd60b935dcd10e48cb12b8 +README.md: 7c8e2e1301f81c5579e7c2cadaf34d0a97270ba7 +README.zh.md: 2f7e66cd9c79c71373618b397e6841b003033bdd diff --git a/packages/self-modification/tool-cordis/README.md b/packages/self-modification/tool-cordis/README.md index f2a65043a1..7c8e2e1301 100644 --- a/packages/self-modification/tool-cordis/README.md +++ b/packages/self-modification/tool-cordis/README.md @@ -87,3 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). - **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. +- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. That matched the host-plane row this package started as (one per process); it becomes observable once several sessions run the same preset concurrently. Per-session temporary plugins would need the group and table keyed by the calling agent. diff --git a/packages/self-modification/tool-cordis/README.zh.md b/packages/self-modification/tool-cordis/README.zh.md index 6674209499..2f7e66cd9c 100644 --- a/packages/self-modification/tool-cordis/README.zh.md +++ b/packages/self-modification/tool-cordis/README.zh.md @@ -87,3 +87,4 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 - **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 +- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。这与本包最初作为宿主平面行的语义一致(每进程一份);只有当多个会话并发运行同一 preset 时才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 01631bb22c..7419982a9f 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1212,7 +1212,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'presentAs(mode: ToolPresentationMode): () => void', - jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */', + jsDoc: '/**\n * Present the calling scope\'s tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset\'s standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents\' models see.\n * @returns the exact disposer that restores the deployment default.\n */', }, { signature: 'register(definition: ToolDefinition): () => void', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c7b7530b09..f2103cd320 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: 2615b253999c798172168ec9d0232eb965b07fbc -README.zh.md: 0712e9c7a61fbcf43939791b3e7cd24af6777c3a +README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62 +README.zh.md: b91908117fd452855a82976515d13165803def43 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 2615b25399..9018b133bb 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -42,6 +42,7 @@ None; projections never assemble or send provider requests. ## Known Limitations and Deferred Work - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. +- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 0712e9c7a6..b91908117f 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -42,6 +42,7 @@ ## 已知限制与暂缓事项 - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 +- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 - **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36be6b1b6..e160b3dd43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../packages/bash/pwsh-sandbox + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../packages/session/session-projection '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference From 8d7c4380aa22ca5c4300559030a13be3000b4dc7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 23:33:26 +0800 Subject: [PATCH 12/23] fix(apiproxy): ensure the recorded composition before reading the baseline `session.history` built its projection baseline in `historyStateFor` and only then awaited `presenterScopeFor`, which is what ensures the recorded preset's standing mount. The unit table is process-wide while the units themselves are registered by preset rows, so on a first cold read the baseline was serialized before `todos` (and every other preset-owned key) existed, and every later read served a complete one. Until this branch, a web lane happened to mount a preset in `beforeAll` for unrelated reasons, which hid the ordering. The baseline is now a thunk the handler reads after the scope resolves. Also covers the roster-less early return of the unjoined-agent warning, which the per-file coverage gate reported uncovered. --- packages/host/apiproxy/src/api-proxy.ts | 41 ++++++++++++++----- .../preset/agent-presets/tests/mount.spec.ts | 13 ++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index ea41122cc9..34abe41f87 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1329,23 +1329,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return undefined } - /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ + /** + * Read one transcript cut without acquiring an Agent owner, plus a DEFERRED + * read of the projection baseline. + * + * The baseline is a thunk rather than a value because the unit table is + * process-wide while the units themselves are registered by preset rows: a + * key like `todos` exists only once its preset's standing mount is composed. + * The caller ensures that mount through {@link presenterScopeFor} — which + * needs the header this function returns — so reading the snapshot eagerly + * would serve a first cold read a page missing every preset-owned key, and + * every later read a complete one. + */ async function historyStateFor( sessionId: SessionId, includeProjections: boolean, - ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + ): Promise<{ + header: SessionHeader + events: SessionEvent[] + readProjections: () => SessionProjectionsBlock | undefined + }> { const attached = ctx.sessions.get(sessionId) if (attached !== undefined) { - const events = [...attached.events] - const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { header: attached.header, events, ...projections === undefined ? {} : { projections } } + return { + header: attached.header, + events: [...attached.events], + readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined, + } } const inspected = await inspectServable(sessionId) - const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined return { header: inspected.meta, events: inspected.events, - ...projections === undefined ? {} : { projections }, + readProjections: () => includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined, } } @@ -2006,7 +2022,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } + let state: Awaited> try { state = await historyStateFor(sessionId, beforeSeq === undefined) } catch (error: unknown) { @@ -2019,11 +2035,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) + // The scope resolves first: ensuring the recorded composition's + // standing mount is what registers its projection units, so the + // baseline below has to be read after it, not beside it. + const scope = await presenterScopeFor(sessionId, state) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, scope) + const projections = state.readProjections() return ok(request, { events: page.events, hasMore: page.hasMore, - ...state.projections === undefined ? {} : { projections: state.projections }, + ...projections === undefined ? {} : { projections }, }) }, diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b2f37e206e..9f2760e704 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -515,6 +515,19 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) + it('says nothing when the deployment configures no roster at all', async () => { + // Presets are optional: every surface except the Web bundle keeps its + // model-facing rows in the host plane, so an agent with a chain of one is + // exactly right there and the diagnostic must stay silent. + const rosterless = await harness({ default: 'standard', roots: [] }) + const warnings: string[] = [] + rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn + + await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') }) + + expect(warnings).toEqual([]) + }) + it('composes an agent that had nothing installed', async () => { // An agent created without a preset has no binding to re-link, so the // switch is its first bind — exactly a mount — and once bound only the From 61d5cb9e41a38ed770e503129f359859ae91d83f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 10:45:15 +0800 Subject: [PATCH 13/23] fix(apiproxy): serve history events and projections from one log position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two defects in the previous commit's ordering fix. @pku-xht: `historyStateFor` copied the attached session's events, the handler then awaited `presenterScopeFor`, and only then read the projection baseline off the still-live Session. An append during that await served events cut at N beside a baseline folded to N+1 — one response describing two moments. The same restructure had also moved the baseline read outside the `try`, so a failing snapshot escaped the structured `internal` error. Both awaits now happen before the cut: `historySourceFor` resolves which session serves the read, `presenterScopeFor` ensures the recorded composition, and `historyCutOf` then reads events and baseline adjacently with nothing between them. The whole sequence is back inside the try. The invariant judged any scoped assembly with a chain of one as an unjoined agent, which rejects a legitimate assembly in a standing preset key (that key has no parent of its own). It now gates on `context.agent` — a scope-only read is not an agent and is out of range by construction rather than by a premise about who else calls `assemble` — and asks the roster's own `composedPreset()` instead of introspecting chain length. The advisory warning uses the same relation. Also from review: the `2026-08-05-per-agent-tool-presentation` note still described `presentAs` as per-agent, which standing mounts made false and this branch's own rewording contradicts; the duplicated "process-wide unit table" argument collapses to the Agent Note with pointers from the five copies; a dead `.sort()` before `arrayContaining`; and change-history narration in the tool-cordis README. --- ...8-05-per-agent-tool-presentation.i18n.yaml | 4 +- .../2026-08-05-per-agent-tool-presentation.md | 6 +- ...26-08-05-per-agent-tool-presentation.zh.md | 6 +- apps/cli/tests/web-agent-presets.e2e.ts | 4 +- packages/acp/acp/src/index.ts | 8 +- packages/bundle/headless/src/index.ts | 9 +- packages/bundle/web-app/cordis.patch.yml | 15 +-- packages/host/apiproxy/src/api-proxy.ts | 111 +++++++++++------- packages/preset/agent-presets/src/index.ts | 29 ++--- .../preset/agent-presets/src/invariant.ts | 37 +++--- .../agent-presets/tests/invariant.spec.ts | 22 ++-- packages/scaffold/server/src/server.ts | 10 +- .../tool-cordis/README.i18n.yaml | 4 +- .../self-modification/tool-cordis/README.md | 2 +- .../tool-cordis/README.zh.md | 2 +- 15 files changed, 146 insertions(+), 123 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml index 24b04ea72f..92610dde43 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md -2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb -2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553 +2026-08-05-per-agent-tool-presentation.md: adb93b51c73d341c153b8fcafe2a08f0a5598478 +2026-08-05-per-agent-tool-presentation.zh.md: fa83bd4daec9d8e6e9e43295bb81af97782b1fdf diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md index 348f7ab0a2..adb93b51c7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -12,16 +12,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to ## Decision -Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there. +Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there. -`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. +`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-mode` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. Two consequences fell out and are load-bearing: - **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. - **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. -The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. +The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md index 4920ee6eb0..fa83bd4dae 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -12,16 +12,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些 ## Decision -把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。 +把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。 -`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 +`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-mode` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 有两个随之而来的结果,且都是承重的: - **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 - **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 -SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 +SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 35e945cba5..1b08bc20df 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -159,7 +159,9 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(Object.keys(projections.snapshot(handle.agent.session).values).sort()) + // A subset assertion: `tasks`, `goal`, and the rest register into the + // same process-wide table, and this is about the meter's three units. + expect(Object.keys(projections.snapshot(handle.agent.session).values)) .toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage'])) } finally { await handle.dispose() diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d5677c75ba..d595c69e69 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -253,11 +253,9 @@ export function apply(ctx: Context, config: AcpConfig): void { validateSessionParams(params) const sessionId = SessionId(randomUUID()) // No preset composition: the ACP bundle keeps the model-facing rows in - // the host plane, so this agent reads them from the global registry - // layer. A deployment that configures `dsh-agent-presets` has to join - // one here first — registration visibility inherits only along the - // `dsh-scope` parent chain, and an agent created without that link - // reads an empty global layer and reaches the model with no tools. + // the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const handle = await agents.create({ sessionId, meta: { cwd: params.cwd }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 162e2c0abb..d818e4bccd 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -107,12 +107,9 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { const selection = defaultModel.currentSelection() // This bundle composes no preset roster, so the model-facing rows sit in the - // host plane and the agent reads them from the global registry layer. A - // deployment that DOES configure `dsh-agent-presets` has to join one here - // first: registration visibility inherits only along the `dsh-scope` parent - // chain, and `AgentPresets.mount()` is the only thing that links an agent to - // it, so an agent created without that link reads an empty global layer and - // reaches the model with no tools at all. + // host plane and the agent reads them from the global layer. A deployment + // that DOES configure one has to join it here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const { agent } = await agents.create({ sessionId: SessionId(`session-${randomUUID()}`), meta: { cwd: process.cwd() }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index bf673df28a..6c564a8560 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -301,16 +301,11 @@ disabled: true # The token METER stays on the host plane; only the compaction backend that -# reads it moves. It takes no configuration at all, keys every fold by Session, -# and registers no tool or prompt section, so one host instance serves every -# preset exactly as before. It also owns the `tokenUsage`/`contextPressure`/ -# `contextBreakdown` projection units, and the projection registry is -# process-wide rather than scope-layered: registered from inside a preset, the -# browser's context meter would appear for a session whose own composition -# mounts no meter as soon as some OTHER session mounted one, and disappear -# entirely in a process that only ever ran `minimal`. That is the same criterion -# `tasks` and `goals` are read by — a Service the host and every preset can see -# belongs to the plane both can see. +# reads it moves. It owns the context-meter projection units, and that table is +# process-wide, so preset ownership would make the meter a function of which +# presets happen to be mounted rather than a per-session fact. Same criterion as +# `tasks` and `goals`; the reasoning has one home in +# `.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md`. - id: compact-basic disabled: true diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 34abe41f87..d2d00410ea 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -686,6 +686,16 @@ function historyPage( * registry). An absent registry means the deployment has no projection seam: * the whole block is absent and clients treat every key as capability-absent. */ +/** + * Which session a transcript read is served from. An attached session is the + * live object and keeps appending, so its events and projection baseline are + * read together in one synchronous step; a detached one is already a frozen + * inspection. + */ +type HistorySource = + | { readonly kind: 'attached'; readonly session: Session } + | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] } + function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined { const registry = ctx.get('sessionProjections') if (registry === undefined) return undefined @@ -1330,39 +1340,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** - * Read one transcript cut without acquiring an Agent owner, plus a DEFERRED - * read of the projection baseline. - * - * The baseline is a thunk rather than a value because the unit table is - * process-wide while the units themselves are registered by preset rows: a - * key like `todos` exists only once its preset's standing mount is composed. - * The caller ensures that mount through {@link presenterScopeFor} — which - * needs the header this function returns — so reading the snapshot eagerly - * would serve a first cold read a page missing every preset-owned key, and - * every later read a complete one. + * Resolve which session one transcript read is served from, without + * acquiring an Agent owner. This is the read's only asynchronous step + * besides ensuring the composition; {@link historyCutOf} takes the cut. + * @param sessionId - the transcript being read. + * @returns the attached session, or the inspected detached header and events. + * @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity. */ - async function historyStateFor( - sessionId: SessionId, - includeProjections: boolean, - ): Promise<{ - header: SessionHeader - events: SessionEvent[] - readProjections: () => SessionProjectionsBlock | undefined - }> { + async function historySourceFor(sessionId: SessionId): Promise { const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) { - return { - header: attached.header, - events: [...attached.events], - readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined, - } - } + if (attached !== undefined) return { kind: 'attached', session: attached } const inspected = await inspectServable(sessionId) - return { - header: inspected.meta, - events: inspected.events, - readProjections: () => includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined, + return { kind: 'detached', header: inspected.meta, events: inspected.events } + } + + /** + * The header and events {@link presenterScopeFor} reads to decide which + * composition a transcript ran under. + * @param source - the live or detached session this read is served from. + * @returns that session's creation header and its events. + */ + function sourceSession(source: HistorySource): PresetBearingSession { + if (source.kind === 'detached') return { header: source.header, events: source.events } + return { header: source.session.header, events: source.session.events } + } + + /** + * One transcript cut: the events and the projection baseline that describe + * the SAME log position. + * + * Synchronous, and the two reads sit next to each other, because an attached + * session keeps appending: an `await` between them would serve events cut at + * N beside a baseline folded to N+1, which is one response describing two + * moments. The caller does its awaiting before this call. + * @param source - the live or detached session this read is served from. + * @param includeProjections - whether the caller asked for the baseline (a tail page does). + * @returns the events and, when asked, the baseline for that same position. + */ + function historyCutOf( + source: HistorySource, + includeProjections: boolean, + ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } { + if (source.kind === 'detached') { + const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined + return { events: source.events, ...projections === undefined ? {} : { projections } } } + const events = [...source.session.events] + const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined + return { events, ...projections === undefined ? {} : { projections } } } /** @@ -2022,9 +2047,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: Awaited> try { - state = await historyStateFor(sessionId, beforeSeq === undefined) + const source = await historySourceFor(sessionId) + // Both awaits happen BEFORE the cut. Ensuring the recorded + // composition's standing mount is what registers its projection + // units, so a first cold read would otherwise serve a baseline + // missing every preset-owned key; and an attached session keeps + // appending, so awaiting between the two reads would pair events cut + // at N with a baseline folded to N+1. + const scope = await presenterScopeFor(sessionId, sourceSession(source)) + const cut = historyCutOf(source, beforeSeq === undefined) + const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope) + return ok(request, { + events: page.events, + hasMore: page.hasMore, + ...cut.projections === undefined ? {} : { projections: cut.projections }, + }) } catch (error: unknown) { if (error instanceof SessionNotFound) { return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) @@ -2035,17 +2073,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - // The scope resolves first: ensuring the recorded composition's - // standing mount is what registers its projection units, so the - // baseline below has to be read after it, not beside it. - const scope = await presenterScopeFor(sessionId, state) - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, scope) - const projections = state.readProjections() - return ok(request, { - events: page.events, - hasMore: page.hasMore, - ...projections === undefined ? {} : { projections }, - }) }, async models(request) { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index b4fcf4452e..bddab50676 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -24,7 +24,7 @@ import { stat } from 'node:fs/promises' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { bindScopeParent, createScope, scopeChainOf, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' @@ -133,24 +133,21 @@ export class AgentPresets extends Service { }, 'agentPresets.settings()') }) - // An agent joins a preset by having its scope key parented to a standing - // mount, and `mount`/`composeFrom` are the only things in the runtime that - // install that link. An agent that never joined keeps a chain of length - // one, so its `tools`, `system-prompt`, and `skill` views resolve against - // the EMPTY global layer and the model simply has nothing — no error, no - // empty catalog to notice, just an agent that cannot act. + // Advisory, not fatal: a synchronous `agent/created` listener that throws + // VETOES publication, and this service must not, because composing an agent + // outside the roster is legal — `recompose` binds exactly such a bare agent + // below, and the ACP, SDK-server, and headless entry points all create one. + // The invariant companion is the check that fails loud, at assembly. Why an + // unjoined agent matters at all has one home: the [Agent + // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md). // - // Advisory rather than fatal, and deliberately not the same observation the - // invariant companion makes. A synchronous `agent/created` listener that - // throws VETOES publication, and this service must not: composing an agent - // outside the roster is legal (`recompose` documents the bare agent it then - // binds, and entry points that predate presets still create one), so - // vetoing would turn a capability gap into an outage. The companion fails - // loud instead, at the later point where the empty world reaches a model. + // Known false positive: a session created bare and bound later by + // `recompose` is warned about once, before its first bind. No shipped flow + // does that today — the Web surface mounts in `setup` and children join + // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { if (this.config.roots.length === 0) return - const key = scopeOf(agent.ctx) - if (key !== undefined && scopeChainOf(key).length > 1) return + if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` + 'its tools, prompt sections, and skill catalog resolve against the empty global layer ' diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index 686c2c1960..e9240a0b2d 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -5,9 +5,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { scopeChainOf } from '@deepseek-ai/dsh-scope' -// Type-only: resolves the `system-prompt/assemble` waterfall this companion joins. +// Type-only: resolves the `system-prompt/assemble` waterfall this companion +// joins, and the `agent` field `dsh-agent` merges into its context. import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-agent' // Imported through the package name, not `./mount.ts`: a module shared between // the two build entry points becomes a third chunk that the published `files` // list does not carry, which `verify-built-package-invariants` rejects. @@ -42,26 +43,28 @@ const install: InvariantInstaller = (ctx, fail) => { } }, { global: true }) - // The join is a scope-parent link, and `AgentPresets.mount()` is the only - // thing in the runtime that installs one. An agent minted without it keeps a - // chain of length one, so its `tools`, `system-prompt`, and `skill` views - // fall back to the empty global layer and the model receives nothing. + // An agent that joined no preset resolves `tools`, `system-prompt`, and + // `skill` against the empty global layer, so the model receives nothing. + // `composedPreset()` is the roster's own answer to "did this agent join", + // read from the live scope chain — see the [Agent + // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md) + // for why the warning beside it is advisory while this one fails. // - // Checked at ASSEMBLY, not at publication: an unjoined agent is legal until - // it addresses a model — `recompose` binds a bare agent as its first link, - // and that agent is unjoined for its whole life up to the switch. Assembling - // a prompt is the point where the empty world stops being a state and - // becomes what the model sees, and it is the only caller that supplies an - // agent scope, so a host assembly (no scope) and a standing mount are both - // correctly out of range. + // Two conditions, each load-bearing. `context.agent` is what makes this an + // AGENT assembly: a scope-only assembly — a cold read resolving presenters + // in a standing key, a diagnostic — is not an agent and must not be judged + // on whether it joined anything. And assembly rather than publication is the + // moment that matters, because an unjoined agent is legal until it addresses + // a model: `recompose` binds a bare agent as its first link, and that agent + // is unjoined for its whole life up to the switch. ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') - const scope = context.scope + const agent = context.agent if (presets !== undefined && presets.config.roots.length > 0 - && scope !== undefined && scopeChainOf(scope).length === 1) { + && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( - 'an agent addressed a model without joining any agent preset while a roster is composed; ' - + 'its tools, prompt sections, and skill catalog resolve against the empty global layer', + `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` + + 'composed; its tools, prompt sections, and skill catalog resolve against the empty global layer', ) } return next() diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 951cb56b90..709ee5ba00 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' @@ -87,25 +87,31 @@ describe('agent-presets invariants', () => { it('rejects an agent that addresses a model without joining any preset', async () => { const ctx = await harness() - // The delegation shape: an agent composed outside the roster keeps a - // scope chain of length one, so every registry view it reads is the empty - // global layer. Publication alone stays legal — `recompose` binds exactly - // such an agent — so nothing fires until that empty world reaches a prompt. + // The delegation shape: an agent composed outside the roster joined no + // standing mount, so every registry view it reads is the empty global + // layer. Publication alone stays legal — `recompose` binds exactly such an + // agent — so nothing fires until that empty world reaches a prompt. const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') }) - await expect(ctx.systemPrompt.assemble({ scope: handle.agent })) + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) .rejects.toThrow(/without joining any agent preset/) }) - it('admits a joined agent and a host assembly that names no scope', async () => { + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('inv-joined'), setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), }) - await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined() + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() // A scopeless assembly belongs to no agent, so it cannot be an unjoined one. await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined() + // Neither can a scope that is not an agent at all: a standing preset key + // has no parent of its own, so a chain-length rule would reject the cold + // read that resolves presenters in it. `context.agent` is what keeps this + // check to agent assemblies. + const standing = await ctx.agentPresets.standingKeyFor('standard') + await expect(ctx.systemPrompt.assemble({ scope: standing })).resolves.toBeDefined() }) }) diff --git a/packages/scaffold/server/src/server.ts b/packages/scaffold/server/src/server.ts index 2f149c160b..6c941509fa 100644 --- a/packages/scaffold/server/src/server.ts +++ b/packages/scaffold/server/src/server.ts @@ -216,12 +216,10 @@ export class HarnessSdkServer { } private async createSession(sessionId: string): Promise { - // No preset composition: the SDK server's compositions keep the - // model-facing rows in the host plane, so this agent reads them from the - // global registry layer. A deployment that configures `dsh-agent-presets` - // has to join one here first — registration visibility inherits only along - // the `dsh-scope` parent chain, and an agent created without that link - // reads an empty global layer and reaches the model with no tools. + // No preset composition: this server's compositions keep the model-facing + // rows in the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/self-modification/tool-cordis/README.i18n.yaml b/packages/self-modification/tool-cordis/README.i18n.yaml index e95201156a..c342e66463 100644 --- a/packages/self-modification/tool-cordis/README.i18n.yaml +++ b/packages/self-modification/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/tool-cordis/README.md -README.md: 946d96c6f0968be3ecb162c52436f10985836b77 -README.zh.md: 8f94783f96e3652e27bdddd9cc7f54081f8c04bf +README.md: be629ca9be5bff1e6f658f05476e8f2880804e44 +README.zh.md: 45f3536d3460106c45051093d097729a366eca1a diff --git a/packages/self-modification/tool-cordis/README.md b/packages/self-modification/tool-cordis/README.md index 946d96c6f0..be629ca9be 100644 --- a/packages/self-modification/tool-cordis/README.md +++ b/packages/self-modification/tool-cordis/README.md @@ -87,4 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). - **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. -- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. That matched the host-plane row this package started as (one per process); it becomes observable once several sessions run the same preset concurrently. Per-session temporary plugins would need the group and table keyed by the calling agent. +- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. Several sessions running one preset concurrently is where that becomes observable. Per-session temporary plugins would need the group and table keyed by the calling agent. diff --git a/packages/self-modification/tool-cordis/README.zh.md b/packages/self-modification/tool-cordis/README.zh.md index 8f94783f96..45f3536d34 100644 --- a/packages/self-modification/tool-cordis/README.zh.md +++ b/packages/self-modification/tool-cordis/README.zh.md @@ -87,4 +87,4 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 - **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 -- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。这与本包最初作为宿主平面行的语义一致(每进程一份);只有当多个会话并发运行同一 preset 时才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 +- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 From ec9a08f1e292081403fadd47365aa8e1e66bdb5b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 11:53:28 +0800 Subject: [PATCH 14/23] =?UTF-8?q?refactor(web):=20rename=20trajectory=20de?= =?UTF-8?q?tail=20tabs=20source=E2=86=92raw=20and=20origin=E2=86=92source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markdown raw-source tab becomes Raw and the message-origin tab becomes Source, keeping the renamed identifiers, labels, and overview links in sync. --- .../src/client/TrajectoryTable.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 9cef2dccfb..90d3ccd3f1 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -155,8 +155,8 @@ type DetailTab = | 'tools' | 'overview' | 'rendered' + | 'raw' | 'source' - | 'origin' | 'input' | 'output' | 'schema' @@ -782,7 +782,7 @@ function RequestOptions({ ) } -function messageOriginLabel(source: unknown): string { +function messageSourceLabel(source: unknown): string { if (typeof source !== 'object' || source === null || Array.isArray(source)) { return 'Unknown' } @@ -805,16 +805,16 @@ function messageOriginLabel(source: unknown): string { return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}` } -function MessageOrigin({ record }: { record: TableRecord }) { +function MessageSource({ record }: { record: TableRecord }) { const source = record.cell.messageSource - if (source === undefined) return

Origin not recorded

+ if (source === undefined) return

Source not recorded

const data = typeof source === 'object' && source !== null ? source : { value: source } return ( ) @@ -879,17 +879,17 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { if (record.cell.kind === 'compacted') { return [ { id: 'overview', label: 'Summary' }, - { id: 'source', label: 'Raw Output' }, + { id: 'raw', label: 'Raw Output' }, ] } if (isMarkdownRecord(record)) { return [ { id: 'overview', label: 'Summary' }, { id: 'rendered', label: 'Preview' }, - { id: 'source', label: 'Source' }, + { id: 'raw', label: 'Raw' }, ...(record.cell.messageSource === undefined ? [] - : [{ id: 'origin', label: 'Origin' } as const]), + : [{ id: 'source', label: 'Source' } as const]), ] } return [ @@ -2772,14 +2772,14 @@ export function TrajectoryTable({ > {selected.cell.messageSource !== undefined && (
-
Origin
+
Source
+ , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + + // The stylesheet's half-viewport cap stays the default; this one overrides it. + expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px') + }) + it('clamps a bubble overflowing the right viewport edge back inside', () => { const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100)) try { From d6d7953ae9860e6e2e4d9253ced0601c5d39c2bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 14:26:18 +0800 Subject: [PATCH 23/23] fix(web): flip a tooltip that the viewport has no room for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bubble clamped horizontally and did nothing vertically, so a long label under an anchor low on the page ran off the bottom. Preset cards made that the common case: custom presets sit at the end of the roster and carry the longest descriptions. A `top` or `bottom` bubble now flips to the other side, and only into a side that genuinely fits — an anchor with room on neither keeps the requested placement rather than oscillating. Sliding the bubble vertically instead would cover the text being read. --- ...11-preset-card-description-clamp.i18n.yaml | 4 +- ...026-08-11-preset-card-description-clamp.md | 1 + ...-08-11-preset-card-description-clamp.zh.md | 1 + packages/client/ui-primitives/src/Tooltip.tsx | 61 +++++++----- .../ui-primitives/tests/tooltip.spec.tsx | 93 ++++++++++++++++--- 5 files changed, 125 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml index 980f932339..21b6957cd2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md -2026-08-11-preset-card-description-clamp.md: cd16080f10d5e4b0286babb9476c926993ee3ffa -2026-08-11-preset-card-description-clamp.zh.md: cb251f6b0c5b1268e210d01c44738de5a61ff2b1 +2026-08-11-preset-card-description-clamp.md: 16ebf371d5af7c9e54fcc37819696b380856d5cb +2026-08-11-preset-card-description-clamp.zh.md: 5b7a18f41e4b3e8acd681a001f6826b19ca7026d diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md index cd16080f10..16ebf371d5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md @@ -21,6 +21,7 @@ Three smaller decisions ride along: - `.cardId` takes the card's free space with `margin-top: auto`, and the description no longer grows. A flex-stretched box leaves the clamp height and the box height disagreeing; sizing the clamped box by content alone keeps the behavior independent of that interaction. - The description carries `title=""`. An empty `title` means the element has no advisory information and the lookup stops there, so the card body's native tooltip does not climb to the description and a cut-off description answers with one bubble instead of two. - `Tooltip` gains an optional `maxWidth`. Its default half-viewport cap renders a description as a slab wider than the settings dialog it belongs to, spilling across the application behind it. +- `Tooltip` also flips a `top` or `bottom` bubble to the other side when the viewport has no room for it, which its horizontal-only clamp previously left unhandled. Custom presets sit at the bottom of the roster and carry the longest descriptions, so the common case put a tall bubble under an anchor low on the page. The flip only moves into a side that genuinely fits, so an anchor with room on neither side keeps the requested placement rather than oscillating; sliding the bubble vertically instead would cover the text being read. A roster row that failed its shape check is badged `Failed to load` (`加载失败`) rather than `Broken` (`已损坏`). Discovery sets `broken` when the composition file is missing, unreadable, or malformed — most often a file the user just edited or deleted — so a damage claim overstates what was observed, and the verbatim reason under the badge already names the file and the fix. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md index cb251f6b0c..5b7a18f41e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md @@ -21,6 +21,7 @@ preset 自行发布 `description`,长度不限,而设置分区把名单渲 - `.cardId` 以 `margin-top: auto` 吃掉卡片的空余空间,描述不再拉伸。被 flex 拉伸的盒子会让截断高度与盒子高度不一致;让截断盒子只按内容定高,行为便不依赖这层交互。 - 描述带有 `title=""`。空 `title` 表示该元素没有提示信息,查找就此停止,因此卡片主体的原生 tooltip 不会向上找到描述,被裁切的描述只回应一个气泡而不是两个。 - `Tooltip` 新增可选的 `maxWidth`。它默认的半视口上限会把描述渲染成比所属设置弹窗还宽的一整块,溢出到背后的应用界面上。 +- `Tooltip` 同时在视口放不下时把 `top` 或 `bottom` 气泡翻到另一侧,此前它只做水平收敛。自定义 preset 位于名单末尾、又恰恰承载最长的描述,因此常见情形正是让一个高气泡挂在页面靠下的锚点之下。翻转只会移向确实放得下的一侧,两侧都放不下时保持请求的位置而不来回摆动;改为垂直滑动则会盖住正在阅读的文本。 形状检查未通过的名单行,徽记从 `Broken`(`已损坏`)改为 `Failed to load`(`加载失败`)。discovery 在组装文件缺失、读不出或格式错误时置位 `broken`——最常见的是用户刚编辑或删除的文件——因此断言损坏超出了观察到的事实,而徽记下方原样展示的原因本就点名了文件与修法。 diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 65ce727bde..fd6bd406e5 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,7 +1,7 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (horizontal overflow clamps, but there -// is no vertical flip on viewport collision and no arrow) — visuals and -// behavior get a proper pass later. +// TODO: interaction is a placeholder (horizontal overflow clamps and a +// vertical collision flips the bubble to the other side, but there is no +// arrow) — visuals and behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so @@ -48,33 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, if (typeof childRef === 'function') childRef(el) else if (childRef != null) (childRef as MutableRefObject).current = el }, [childRef]) - const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + // The anchor's edges rather than final coordinates: a vertical flip has to + // re-derive the bubble's own top from the opposite edge. + const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null) + // Where the bubble actually sits, which is the requested side until the + // viewport refuses it. + const [placement, setPlacement] = useState(side) const bubble = useRef(null) const resolvedLabel = pos === null ? null : typeof label === 'function' ? label() : label - // Horizontal viewport clamp: fixed positioning knows nothing about edges, so - // a centered bubble near the right edge would clip. Each measurement resets - // the base position before applying a direct style offset, allowing a shorter - // label or wider viewport to release a previous clamp without another render. + const y = pos === null + ? 0 + : placement === 'right' + ? pos.top + (pos.bottom - pos.top) / 2 + : placement === 'top' ? pos.top - 8 : pos.bottom + 8 + const EDGE_MARGIN = 12 + // Viewport fit: fixed positioning knows nothing about edges, so a centered + // bubble near the right edge would clip and a long label under an anchor low + // on the page would run off the bottom. Horizontally the bubble slides back + // inside; vertically it flips to the opposite side, which is the only move + // that does not cover the anchor being read. Each measurement resets the base + // position first, so a shorter label or a larger viewport releases a previous + // adjustment without another render. useLayoutEffect(() => { if (pos === null) return - const clamp = () => { + const fit = () => { const el = bubble.current /* v8 ignore next -- pos is set only while the bubble is mounted. */ if (el === null) return - const EDGE_MARGIN = 12 el.style.left = `${pos.x}px` const r = el.getBoundingClientRect() let dx = 0 if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left el.style.left = `${pos.x + dx}px` + if (side === 'right') return + // Flip only into a side that genuinely fits, so an anchor with room on + // neither side keeps the requested placement instead of oscillating. + const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN + const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN + if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top') + if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom') } - clamp() - window.addEventListener('resize', clamp) - return () => { window.removeEventListener('resize', clamp) } - }, [pos, resolvedLabel]) + fit() + window.addEventListener('resize', fit) + return () => { window.removeEventListener('resize', fit) } + }, [placement, pos, resolvedLabel, side]) const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -102,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, /* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */ if (el === null) return const r = el.getBoundingClientRect() - setPos(side === 'right' - ? { x: r.right + 10, y: r.top + r.height / 2 } - : side === 'top' - ? { x: r.left + r.width / 2, y: r.top - 8 } - : { x: r.left + r.width / 2, y: r.bottom + 8 }) + // Every show starts from the requested side; the fit pass flips it only + // where this anchor's position demands it. + setPlacement(side) + setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom }) } const showAfterHoverDelay = () => { cancelShow() @@ -137,8 +156,8 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, {resolvedLabel} diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 06eec65c01..a884b58615 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -173,19 +173,88 @@ describe('Tooltip', () => { } }) + /** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */ + const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) => + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + const [top, bottom] = this.getAttribute('role') === 'tooltip' + ? [0, bubbleHeight] + : [anchorTop, anchorBottom] + return { + left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}), + } + }) + it('supports top placement for anchors at the viewport bottom', () => { - render( - - - , - ) - fireEvent.mouseEnter(screen.getByText('anchor')) - const bubble = screen.getByRole('tooltip') - expect(bubble.getAttribute('data-side')).toBe('top') - // jsdom rects are all-zero: top placement lands at the -8 gutter and the - // zero-width measured rect clamps left to the 12px edge margin. - expect(bubble.style.left).toBe('12px') - expect(bubble.style.top).toBe('-8px') + const spy = placed(700, 720, 20) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + // There is room above, so the requested side stands: the bubble's own + // top sits at the anchor's top less the 8px gutter. + expect(bubble.getAttribute('data-side')).toBe('top') + expect(bubble.style.top).toBe('692px') + expect(bubble.style.left).toBe('150px') + } finally { + spy.mockRestore() + } + }) + + it('flips a bottom bubble above an anchor with no room below', () => { + // jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at + // 700 would run off, and there is room for it above. + const spy = placed(600, 700, 300) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('top') + expect(bubble.style.top).toBe('592px') + } finally { + spy.mockRestore() + } + }) + + it('flips a top bubble below an anchor with no room above', () => { + const spy = placed(10, 40, 100) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('bottom') + expect(bubble.style.top).toBe('48px') + } finally { + spy.mockRestore() + } + }) + + it('keeps the requested side when neither side fits', () => { + // A bubble taller than the viewport has no home; oscillating between the + // two would be worse than honouring the request. + const spy = placed(300, 400, 900) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom') + } finally { + spy.mockRestore() + } }) it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {