From cd6bd5c1882b455df0574f2d3dc260f3cdd67419 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 11:48:34 +0800 Subject: [PATCH 001/262] fix(llm): honor DeepSeek SSE keep-alives --- ...-21-bounded-llm-request-recovery.i18n.yaml | 4 +-- ...2026-06-21-bounded-llm-request-recovery.md | 4 +-- ...6-06-21-bounded-llm-request-recovery.zh.md | 4 +-- .../fixtures/deepseek-defaults.cordis.yml | 1 + .../headless-agent/tests/headless.snapshot.ts | 23 +++++++++---- packages/llm/llm-deepseek/README.i18n.yaml | 4 +-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 11 ++++-- packages/llm/llm-deepseek/src/sse.ts | 19 +++++++---- .../llm/llm-deepseek/tests/adapter.spec.ts | 34 +++++++++++++++++++ packages/llm/llm-deepseek/tests/sse.spec.ts | 10 ++++++ packages/util/timeout/README.i18n.yaml | 4 +-- packages/util/timeout/README.md | 4 +-- packages/util/timeout/README.zh.md | 4 +-- packages/util/timeout/src/index.ts | 17 ++++++++-- packages/util/timeout/tests/timeout.spec.ts | 22 ++++++++++++ 17 files changed, 134 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index bf6f030683..d527bb9e7b 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 24725dcf300cf69e9cc72580d0c8afe937d4e2b9 -2026-06-21-bounded-llm-request-recovery.zh.md: 5f03a65b00be8d3349addce82e4f3faa2af1fe7e +2026-06-21-bounded-llm-request-recovery.md: 122f30118ebe3f213a887e56a9d3505e78b23070 +2026-06-21-bounded-llm-request-recovery.zh.md: 8af62cef952eba929dd770df15fdc0666f575666 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 24725dcf30..122f30118e 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -74,9 +74,9 @@ Adapters perform one provider request per `stream()` call. The pi-ai adapter rem ### Bound stalled streams where they can be stopped -Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. +Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to adapter-recognized provider activity; time a consumer spends between `next()` calls is not provider idle time. DeepSeek SSE comments count as transport activity but never become `StreamChunk` values or session-log events. -`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. +`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Out-of-band transport activity calls `pulse()` to rearm an outstanding demand without yielding a value. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 5f03a65b00..8af62cef95 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -74,9 +74,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在能够终止停滞流的位置施加边界 -每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。 +每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到适配器识别到提供方活动为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。DeepSeek SSE(Server-Sent Events)注释计为传输活动,但绝不会成为 `StreamChunk` 值或会话日志事件。 -`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 +`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。带外传输活动会调用 `pulse()`,在不产生值的情况下为尚未完成的需求重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此契约。 diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index cd472f737d..829af9f813 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -8,6 +8,7 @@ apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled + streamIdleTimeoutMs: 100 - id: cli-agent config: provider: deepseek-official diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 8b48165a83..fd1bbd3086 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -66,12 +66,21 @@ async function deepseekDefaultsServer(): Promise { request.on('end', () => { requests.push(JSON.parse(body) as JsonObject) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + let keepAlives = 3 + const write = (): void => { + if (keepAlives-- > 0) { + response.write(': keep-alive\n\n') + setTimeout(write, 60) + return + } + response.end([ + 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + } + setTimeout(write, 60) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -297,7 +306,7 @@ describe('headless stream-json snapshots', () => { `) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('logs and sends the DeepSeek adapter maxTokens default through the one-shot app', async () => { + it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { const server = await deepseekDefaultsServer() try { const result = await runLoaderSmoke({ diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..cb18c83eff 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: 2ecdb4330e5871bbaf0da6fc583083a06c935486 +README.zh.md: 63eb7be330806b668e12867ed906d0d88acaff1f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..2ecdb4330e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -46,7 +46,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..63eb7be330 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -46,7 +46,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 85985c41d8..c5f9655fa1 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -215,7 +215,13 @@ export class DeepSeekAdapter extends LlmAdapter { ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]() + const iterator = this.request( + options, + watchdog.signal, + connection, + apiKey, + () => { watchdog.pulse() }, + )[Symbol.asyncIterator]() let exhausted = false try { while (true) { @@ -256,6 +262,7 @@ export class DeepSeekAdapter extends LlmAdapter { signal: AbortSignal, connection: DeepSeekConnectionOptions, apiKey: string, + onComment: () => void, ): AsyncIterable { const body = serializeRequest(options, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the @@ -321,6 +328,6 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') } - yield* translate(parseSse(response.body)) + yield* translate(parseSse(response.body, onComment)) } } diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index a8807a857c..9126a18940 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,11 +1,12 @@ /** * Decode an SSE byte stream into event `data` payloads. Framing — chunk * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, - * multi-`data:` joining — is `eventsource-parser`'s; this module keeps only - * the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns - * final flushing, and EOF before it raises {@link LlmError}. Framing is - * spec-strict: an event dispatches only on its blank-line terminator, so an - * unterminated tail at EOF is truncation, not a flushable payload. + * multi-`data:` joining — is `eventsource-parser`'s. Comments are reported + * only through an optional transport-activity callback. This module keeps the + * DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final + * flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict: + * an event dispatches only on its blank-line terminator, so an unterminated + * tail at EOF is truncation, not a flushable payload. * * @module dsh-llm-deepseek/sse */ @@ -21,12 +22,16 @@ export const DONE = '[DONE]' * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends * without it (truncated response — the model call cannot be trusted). * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. + * @param onComment - optional transport-activity callback; comments never enter the yielded payload stream. * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. */ -export async function* parseSse(stream: ReadableStream): AsyncGenerator { +export async function* parseSse( + stream: ReadableStream, + onComment?: (comment: string) => void, +): AsyncGenerator { const events = stream .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()) + .pipeThrough(new EventSourceParserStream({ onComment })) for await (const { data } of events) { yield data if (data === DONE) return diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..8240dea7e6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -545,6 +545,40 @@ describe('DeepSeekAdapter against a mock server', () => { fetchSpy.mockRestore() } }) + + it('keeps an idle provider read alive through SSE comments', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) + setTimeout(() => { + controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) + controller.close() + }, 225) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) + try { + const chunks: string[] = [] + const drain = (async () => { + for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + chunks.push(chunk.type) + } + })() + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await expect(drain).resolves.toBeUndefined() + expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + } finally { + fetchSpy.mockRestore() + } + }) }) describe('plugin registration and config', () => { diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 7ebb494a4d..67160d8079 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -31,6 +31,16 @@ describe('parseSse', () => { expect(events).toEqual(['{"a":1}', DONE]) }) + it('reports comments out of band without yielding them', async () => { + const comments: string[] = [] + const events = await collect(parseSse( + bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'), + (comment) => { comments.push(comment) }, + )) + expect(comments).toEqual(['keep-alive']) + expect(events).toEqual(['{"a":1}', DONE]) + }) + it('stops yielding after DONE even when more data follows', async () => { const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) expect(events).toEqual([DONE]) diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml index 0436cc4d34..a578a0ac4f 100644 --- a/packages/util/timeout/README.i18n.yaml +++ b/packages/util/timeout/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/util/timeout/README.md -README.md: 11c55a45a1255e14fb551e42ba3965453dbd94ae -README.zh.md: 8b63f00595139f9af2e9a31e8ab3c3494088e0ff +README.md: 8892b2dce53b5c315c088430ed3fcf386a0f3101 +README.zh.md: ec99f38ff92890c854a1f902e56b2278a42318b6 diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index 11c55a45a1..8892b2dce5 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti |---|---| | `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. | | `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. | -| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. | +| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand or `pulse()` activity rearms; disposal clears; concurrent demand rejects. | | `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. | | `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). | | `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. | @@ -48,7 +48,7 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired. -For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. +For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. Call `watchdog.pulse()` when transport activity does not yield an iterator value. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. ## What does NOT get a timeout diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md index 8b63f00595..ec99f38ff9 100644 --- a/packages/util/timeout/README.zh.md +++ b/packages/util/timeout/README.zh.md @@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti |---|---| | `clampTimeout(requested, def, max, name?)` | 验证调用方可选的、值为正且有限的提示,从 `def` 填充,并限制在 `max` 以内。如果提示为非正数或非有限数,则抛出错误(包含 `name`)。 | | `deadline(upstream, timeoutMs, code)` | 将 `upstream` 取消与超时融合为一个 `AbortSignal`(`AbortSignal.any`);超时携带 `TimeoutReason`。`[Symbol.dispose]` 清除 timer。 | -| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 | +| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求或 `pulse()` 活动会重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 | | `MAX_TIMER_DELAY_MS` | Node 在不将延迟限制为 1 毫秒时可调度的最大延迟(`2_147_483_647`)。负责 timer 的配置不得超过该值。 | | `timeoutOf(signal \| { reason }, code?)` | 从已中止的信号/错误中恢复 `TimeoutReason`,否则返回 `undefined`,即超时与取消的分类器。传入 `code` 可仅匹配这个 deadline 的 timer(见下文的嵌套)。 | | `TimeoutReason` | 标记在超时中止上的内部原因(`code` + `timeoutMs`)。它不是公开错误;提供方将其转换为自己的错误/字段。 | @@ -48,7 +48,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout 将你自己的 `code` 传给 `timeoutOf`,以便分类可在嵌套中组合:当你收到的 `upstream` *本身*就是 deadline 信号时(未来启动每次调用 deadline 的 `tools/execute` 中间件),如果外层 timer 首先触发,`AbortSignal.any` 会保留外层 `TimeoutReason`。将范围限定为你的 `code`,可将外部超时视为普通 upstream 取消,这才是你所属功能视角下的正确分类,而不会在本地 timer 尚未到期时就声称自己超时。 -对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 +对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。当传输活动不产生迭代器值时,调用 `watchdog.pulse()`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 ## 哪些操作不设置超时 diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index a9bd47eb08..3fc4d2387b 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -72,6 +72,8 @@ export interface IdleWatchdog { * @returns the iterator's next result. */ next(iterator: AsyncIterator): Promise> + /** Rearm an outstanding demand after transport activity that yields no iterator value; otherwise a no-op. */ + pulse(): void /** Clear an armed timer; safe to call once at the owning stream's exit. */ [Symbol.dispose](): void } @@ -135,15 +137,20 @@ export function idleWatchdog( let outstanding = false let disposed = false + const arm = (): void => { + if (timer !== undefined) clearTimeout(timer) + timer = setTimeout(() => { + timeout.abort(new TimeoutReason(code, timeoutMs)) + }, timeoutMs) + } + return { signal, async next(iterator: AsyncIterator): Promise> { if (disposed) throw new Error('idleWatchdog is disposed') if (outstanding) throw new Error('idleWatchdog next is already outstanding') outstanding = true - timer = setTimeout(() => { - timeout.abort(new TimeoutReason(code, timeoutMs)) - }, timeoutMs) + arm() try { return await iterator.next() } finally { @@ -152,6 +159,10 @@ export function idleWatchdog( outstanding = false } }, + pulse(): void { + if (disposed || !outstanding) return + arm() + }, [Symbol.dispose](): void { if (disposed) return disposed = true diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index 11779c915f..af0389d3d7 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -229,6 +229,28 @@ describe('idleWatchdog', () => { await expect(secondNext).rejects.toBe(stableSignal.reason) }) + it('rearms outstanding demand on an out-of-band activity pulse', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers>() + const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + watchdog.pulse() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.aborted).toBe(false) + + const next = watchdog.next({ next: () => pending.promise }) + await vi.advanceTimersByTimeAsync(99) + watchdog.pulse() + await vi.advanceTimersByTimeAsync(99) + expect(watchdog.signal.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 }) + pending.reject(watchdog.signal.reason) + await expect(next).rejects.toBe(watchdog.signal.reason) + + watchdog[Symbol.dispose]() + watchdog.pulse() + }) + it('keeps an earlier upstream abort distinct from its own timeout', async () => { vi.useFakeTimers() const upstream = new AbortController() From bf70345d81a35c1e821aa88a96b1dac7e3f38bb2 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 12:00:14 +0800 Subject: [PATCH 002/262] test(snapshot): widen keep-alive timing margin --- .../headless-agent/tests/fixtures/deepseek-defaults.cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index 829af9f813..8a1ca177f4 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -8,7 +8,7 @@ apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled - streamIdleTimeoutMs: 100 + streamIdleTimeoutMs: 150 - id: cli-agent config: provider: deepseek-official From 6cd0eea52d92dc3596851316623674e4ec502fb9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:37:19 -0700 Subject: [PATCH 003/262] docs: propose Task Surface protocol --- .../feature/2026-08-04-task-surface.i18n.yaml | 6 + .../feature/2026-08-04-task-surface.md | 232 ++++++++++++++++++ .../feature/2026-08-04-task-surface.zh.md | 232 ++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-08-04-task-surface.md create mode 100644 .agents/notes/proposed/feature/2026-08-04-task-surface.zh.md diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml new file mode 100644 index 0000000000..36e7d8c5d6 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.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/proposed/feature/2026-08-04-task-surface.md +2026-08-04-task-surface.md: 780d5d884c83ab47535fff95efcd80b0b7f0181f +2026-08-04-task-surface.zh.md: 38e6787ee5a4d86261e79c66d99f81614bdb5871 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md new file mode 100644 index 0000000000..780d5d884c --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -0,0 +1,232 @@ +# Agent Note: Task Surface for structured session interaction + +Status: proposed + +English | [中文](2026-08-04-task-surface.zh.md) + +## Problem + +Some tasks are awkward to finish through alternating prose messages. Comparing several options, reordering a plan, reviewing a table, or filling a small set of related fields all work better as one structured interaction. Today an agent can describe such an interaction, but it cannot ask the Web client to render one without adding a permanent product component or generating executable Client Plugin code. + +Those two workarounds put ownership in the wrong place. Product-specific components require a new trigger and release for every task shape. Generated code has far more authority and lifecycle cost than a one-turn form needs. It also makes the presentation, rather than the user's conclusion, the durable artifact. + +The missing contract is a bounded, replayable description of a temporary UI that belongs to one Session and one tool occurrence. The product should own validation, placement, interaction mechanics, and submission. The agent should own the task-specific copy, data, and choice of supported components. + +## Proposal + +Add **Task Surface**, a versioned declarative model rendered by a normal Web Client Plugin. One stable model-facing tool, `show_task_surface`, publishes the model. A successful call ends the current turn. The user edits and submits the rendered panel; the Host records the submission as one ordinary visible user message and starts the next turn. + +Task Surface is the default structured-UI path when all of the following hold: + +- the interaction belongs to the current Session and current task; +- its behavior fits the declared component set; +- it needs no background execution or new runtime authority; and +- the useful durable result is the user's submitted conclusion, not the panel itself. + +This is one trigger, not a family of product heuristics. The agent calls `show_task_surface` explicitly. A user may ask the agent to use a Task Surface in ordinary language. Products do not inspect tool names or task topics to open bespoke panels, and repeated use does not automatically turn a Task Surface into a Plugin. + +Short blocking questions remain with [`ask_user_question`](../../implemented/feature/2026-07-29-ask-question-web-presentation.md). Plain explanation remains chat. Cross-Session navigation, background behavior, new services, or durable custom UI belongs to the Generated Client Plugin workflow. + +## Declarative model + +`TaskSurfaceModelV1` is JSON. It contains content blocks, input fields, and one submit label; it contains no code, callbacks, selectors, HTML, CSS, URLs to executable assets, or expression language. This type is unrelated to core Session's existing `SurfaceManager`/`SurfaceOp` message-reduction types; Task Surface is a product interaction protocol. + +```ts ignore-check +interface TaskSurfaceModelV1 { + version: 1 + title: string + description?: string + sections: TaskSurfaceSection[] + fields?: TaskSurfaceField[] + submit: { label: string } +} + +interface TaskSurfaceSection { + id: string + title?: string + layout?: 'stack' | 'grid' + columns?: 2 | 3 + blocks: TaskSurfaceBlock[] +} + +type TaskSurfaceBlock = + | { kind: 'markdown'; text: string } + | { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] } + | { kind: 'table'; columns: { id: string; label: string }[]; rows: Record[] } + | { kind: 'diff'; path?: string; before: string | null; after: string; language?: string } + | { kind: 'notice'; tone: 'neutral' | 'info' | 'warning'; text: string } + +type TaskSurfaceField = + | { kind: 'text'; id: string; label: string; multiline?: boolean; required?: boolean; initial?: string } + | { kind: 'choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string } + | { kind: 'multi-choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] } + | { kind: 'toggle'; id: string; label: string; initial?: boolean } + | { kind: 'order'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] } + +interface TaskSurfaceOption { id: string; label: string; detail?: string } +``` + +The renderer controls typography, spacing, responsive layout, focus order, keyboard behavior, and theme tokens. `grid` is a layout hint: it collapses when the available width cannot support the requested columns. Markdown uses the product's supported Markdown subset. Unknown versions or union arms use the generic tool-result fallback instead of partial interpretation. + +Version 1 deliberately omits conditional fields, client-side data fetching, charts, file uploads, and arbitrary event handlers. A new block or field kind is a protocol change with a parser, renderer, accessibility behavior, fallback, and replay fixture in the same change. + +Limits are schema-backed configuration on the Task Surface service. The initial defaults are 64 KiB for the normalized model, 64 blocks, 32 fields, 200 table rows, and 32 KiB for a submission. IDs are unique within the model; field values must match their declarations; unknown fields are rejected. The limits bound log, DOM, and prompt costs without changing the protocol. + +## Tool and presentation contract + +`show_task_surface` accepts `{ model: TaskSurfaceModelV1 }`. The Host parses and normalizes the complete model, rejects the call when that Session already has an open Task Surface, mints `surfaceId`, and returns canonical `{ surfaceId, model }` with the normalized model. `presentationMeta` persists `value.model`, so the projector and executor cannot disagree about normalization. The Native result names the Surface and explains that an ordinary message bypasses it when the client cannot render the panel. The tool then calls `exec.concludeTurn()` so the agent does not continue past the requested human checkpoint. + +The tool definition sets `exclusive: true`, and the tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because Code Mode dispatch is nested and cannot carry its presentation metadata to the outer result. + +The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`: + +```ts ignore-check +interface TaskSurfacePresentationMeta { + kind: 'dsh/task-surface' + version: 1 + surfaceId: string + model: TaskSurfaceModelV1 +} +``` + +The tool keeps a generic [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md). The keyed Web row reads the tagged metadata already retained on `ToolResultNode`; no new render-intent arm or presentation registry is required. Clients without Task Surface support render the ordinary result content. + +The Web plugin statically registers one keyed `conversation.chat.toolview` entry for `show_task_surface`, following the [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) and [slot registration](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md) contracts. The row renders a compact summary when settled and expands the declarative panel inline. The model does not choose a conversation tab, details column, modal, pixel position, or z-index. A later placement change remains a renderer decision and does not alter logged models. + +## Submission contract + +The Task Surface domain exposes three operations through the Host transport. `submit` is the only one that admits a user message: + +```ts ignore-check +type TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } +type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } + +interface TaskSurfaceService { + getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + submit(input: SubmitTaskSurfaceRequest): Promise + dismiss(input: DismissTaskSurfaceRequest): Promise +} + +interface SubmitTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + submissionId: TaskSurfaceSubmissionId + values: Record + note?: string +} + +type SubmitTaskSurfaceResult = + | { accepted: true; messageId: MessageId } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + +type GetActiveTaskSurfaceResult = + | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { active: false; reason: 'not-open' } + +interface DismissTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + dismissalId: TaskSurfaceDismissalId +} + +type DismissTaskSurfaceResult = + | { dismissed: true; eventSeq: number } + | { dismissed: false; reason: 'not-open' | 'stale' } +``` + +The Host resolves the exact successful `show_task_surface` occurrence, revalidates the submitted values against its persisted model, and admits the response through the normal Session queue. The response becomes a user-role message with a merge-extensible source: + +```ts ignore-check +interface TaskSurfaceCorrelation { + version: 1 + submissionId: TaskSurfaceSubmissionId + callId: CallId + surfaceId: string + values: Record +} + +interface TaskSurfaceUserMessageSource { + kind: 'user' + rpcId: RpcId + taskSurface: TaskSurfaceCorrelation +} +``` + +The browser-safe domain package owns `TaskSurfaceCorrelation` and its branded `submissionId`. ApiProxy owns the transport augmentation that combines it with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction. + +The product shell owns collapse and dismiss. Collapse is local view state and sends nothing. `taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` appends one `task-surface/dismissed` Session event and does not start a turn; the exact event closes the projection and updates the transcript row. Retries reuse `dismissalId` and return the original result without appending another event. + +Submission is transactional at the client boundary. The panel disables submit while admission is in flight and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId`; the Host admits one user message for one accepted Surface. + +There is a short interval between queue admission and the durable `user/message`. The generic queued-message DTO therefore retains `Message.source`. A queued message with matching Task Surface correlation keeps the panel disabled; if that queue item is discarded, the pending state clears and the draft becomes editable again. The Host holds a process-local single-flight claim for the same interval, then releases it on commit, rejection, or discard. The queue is coordination state, not a second durable lifecycle record. + +## Lifecycle and recovery + +The Session log is the authority. A small `taskSurface` unit in the existing [Session projection system](../architecture/2026-07-27-session-projection-and-command-log.md) folds successful surface result metadata and later user-message sources into this state: + +```ts ignore-check +interface TaskSurfaceProjection { + active: { callId: CallId; surfaceId: string } | null +} +``` + +One Session has at most one open Task Surface. A successful result opens it. A matching Task Surface user message or dismissal event closes it. A later ordinary user message also closes it as an explicit bypass; another `show_task_surface` call fails until one of those events closes the active occurrence. Rewind and fork derive their active occurrence by folding the resulting log; no separate Surface database participates. + +The full model remains on its `tool/result.meta`; the projection carries only the active identity. When that result is outside the loaded history window, `taskSurface.getActive({ sessionId, surfaceId })` reads the exact occurrence from the Session log and returns `{ callId, surfaceId, model }` after revalidating the metadata. A missing or closed occurrence returns `not-open`. Refresh and reconnect therefore do not depend on the active result fitting in the history tail and do not duplicate the model into every projection baseline. + +The Web plugin keeps unsubmitted values in a bounded, per-Session persisted slot store keyed by `surfaceId`; they never enter the Session log, prompt, or long-term memory. Submitted values live in the accepted user message, so losing a browser draft cannot erase a conclusion. + +## Package boundaries and dependencies + +The capability is split where ownership changes: + +| Package | Responsibility | +|---|---| +| `packages/task-surface/task-surface` | Browser-safe model/types and correlation, parser, limits, submission validator/formatter, Session event extension, projection unit, and Host service contract | +| `packages/task-surface/tool-task-surface` | `show_task_surface`, canonical output, presentation metadata, generic render intent, active-Surface check, and `concludeTurn()` behavior | +| `packages/client/ui-task-surface` | Static keyed tool row, declarative Web renderer, per-Session draft store, and submit client | +| `packages/host/apiproxy` | Typed active-read/submit/dismiss transport, user-source augmentation, and queued-source carriage; delegates validation and admission to the Task Surface service | + +The implementation depends on the existing message log, canonical tool output, tagged render intents, Session projection, per-Session declared slot stores, and slot lifecycle. It does not depend on runtime Client Plugin creation. The generated Client Plugin workflow may use Task Surface to present a review form, but neither protocol owns or activates the other. + +## Delivery stages + +1. Land the model/parser, projection unit, `show_task_surface`, presentation metadata, static Web row, and generic fallback with read-only blocks. +2. Add fields, persisted drafts, Host-validated submit/dismiss, queued-source carriage, and visible user-message admission. +3. Add only component kinds justified by real tasks and two consumers or a clear generic fallback. A separate explicit user action may start the generated Plugin authoring workflow, but it creates a candidate; it never promotes code directly. + +## Alternatives considered + +**Add product-specific triggers and panels.** Rejected because every new task shape would couple agent behavior to a shipped product component. Product code should define one admitted component vocabulary and placement policy; the agent chooses among it explicitly. + +**Render arbitrary HTML, CSS, or JavaScript from the tool call.** Rejected because it turns a temporary interaction into executable Client Plugin code without the build, preview, evaluation, approval, or rollback lifecycle that code requires. + +**Extend `userInteraction.ask()` with a large form.** Rejected for this contract. `ask()` is a blocking request/response operation used when a running tool cannot continue without a short answer. A Task Surface ends the turn, may remain open across refreshes, and submits its result as the next visible user turn. + +**Register one dynamic `conversation.view` per call.** Rejected because the view ledger is global while its render scope is per Session, and because transient task identity would become registration identity. One static keyed toolview keeps occurrence data in the logged call where it belongs. + +**Keep the model only in the canonical tool value.** Rejected because canonical values are not persisted. Replay requires the normalized model in `presentationMeta`. + +**Store the panel in long-term memory.** Rejected because layout and draft state are not the reusable fact. Memory may retain the submitted user conclusion under existing memory policy. + +## Acceptance criteria + +- A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `code`-only mode does not advertise it. +- Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. +- Refresh, reconnect, Session switching, fork, and rewind produce the lifecycle state implied by the log; `getActive` recovers a model outside the history tail, and no panel leaks across Sessions. +- Unsupported versions, malformed metadata, and absent client capability fall back to readable tool-result content with the ordinary-message bypass; nested calls and calls made while another Surface is active fail without opening a Surface. +- The parser enforces IDs, union shapes, field values, and configured byte/count limits before the panel becomes actionable. +- Keyboard-only operation, focus restoration, accessible names, narrow layouts, both themes, and zh/en product chrome are covered by component tests. +- Keyless browser composition covers show, edit, retry after rejected admission, queued/discarded submission, durable submit, dismiss, refresh recovery, and double-submit idempotency. +- Prefix snapshots show one stable tool definition regardless of the task-specific model; only the call arguments and later user conclusion vary. +- Unloading the Web plugin disposes its row and draft stores through the owning Fiber without changing the durable transcript. + +## Risks + +The first component set may be either too small for useful tasks or broad enough to become a weak application framework. Usage evidence should decide additions; v1 has no expression language or network behavior. + +Large tables and Markdown can still create expensive DOM even inside byte limits. The renderer must virtualize or truncate where needed while preserving a readable fallback and explicit counts. + +A product-formatted submission can become verbose when many fields are filled. The formatter needs a deterministic compact form and must preserve every submitted value without repeating the complete display model. + +Browser-local draft persistence can retain sensitive unsubmitted text. The store needs the stated byte bound, per-Session keys, explicit clearing after acceptance, and the same storage posture as the existing conversation draft. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md new file mode 100644 index 0000000000..38e6787ee5 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -0,0 +1,232 @@ +# Agent Note: 用于结构化会话交互的 Task Surface + +Status: proposed + +[English](2026-08-04-task-surface.md) | 中文 + +## 问题 + +有些任务很难通过交替发送文本消息来完成。比较多个选项、调整计划顺序、审阅表格,或填写一小组关联字段,都更适合在一次结构化交互中处理。目前,agent(智能体)可以描述这类交互,但若不增加永久的产品组件或生成可执行的客户端插件代码,就无法要求 Web 客户端渲染这类交互。 + +这两种变通方案的职责归属都不合理。产品专用组件要求每种任务形态都新增触发方式并发布新版本。对于只需一个轮次的表单,生成代码所拥有的权限和生命周期成本都远超实际需要。这样做还会把展示界面而非用户结论变成持久产物。 + +目前缺少这样一份契约:用有界、可回放的描述来定义临时 UI,并让它只属于一个会话和一次工具调用实例。产品应当负责校验、放置、交互机制和提交;agent 应当负责特定任务的文案、数据,以及从受支持组件中作出选择。 + +## 提案 + +新增 **Task Surface**:一种由普通 Web 客户端插件渲染、带版本的声明式模型。面向模型提供一个稳定工具 `show_task_surface`,用于发布该模型。调用成功后,当前轮次结束。用户编辑并提交渲染出的面板;Host 将提交内容记录为一条普通的可见用户消息,并开始下一轮。 + +同时满足以下条件时,Task Surface 是默认的结构化 UI 路径: + +- 交互属于当前会话和当前任务; +- 行为可以由已声明的组件集合表达; +- 不需要后台执行或新增运行时权限; +- 有价值的持久结果是用户提交的结论,而不是面板本身。 + +这里定义的是一个触发方式,不是一组产品启发式规则。agent 会显式调用 `show_task_surface`。用户可以通过普通语言要求 agent 使用 Task Surface。产品不会根据工具名称或任务主题打开专用面板;重复使用也不会自动把 Task Surface 转为插件。 + +简短的阻塞式问题仍由 [`ask_user_question`](../../implemented/feature/2026-07-29-ask-question-web-presentation.md) 处理。纯文本说明仍留在聊天中。跨会话导航、后台行为、新服务或持久自定义 UI 则属于 Generated Client Plugin 工作流。 + +## 声明式模型 + +`TaskSurfaceModelV1` 使用 JSON。它包含内容块、输入字段和一个提交标签;不包含代码、回调、选择器、HTML、CSS、可执行产物的 URL,也不包含表达式语言。该类型与核心会话中现有的 `SurfaceManager`/`SurfaceOp` 消息归约类型无关;Task Surface 是一套产品交互协议。 + +```ts ignore-check +interface TaskSurfaceModelV1 { + version: 1 + title: string + description?: string + sections: TaskSurfaceSection[] + fields?: TaskSurfaceField[] + submit: { label: string } +} + +interface TaskSurfaceSection { + id: string + title?: string + layout?: 'stack' | 'grid' + columns?: 2 | 3 + blocks: TaskSurfaceBlock[] +} + +type TaskSurfaceBlock = + | { kind: 'markdown'; text: string } + | { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] } + | { kind: 'table'; columns: { id: string; label: string }[]; rows: Record[] } + | { kind: 'diff'; path?: string; before: string | null; after: string; language?: string } + | { kind: 'notice'; tone: 'neutral' | 'info' | 'warning'; text: string } + +type TaskSurfaceField = + | { kind: 'text'; id: string; label: string; multiline?: boolean; required?: boolean; initial?: string } + | { kind: 'choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string } + | { kind: 'multi-choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] } + | { kind: 'toggle'; id: string; label: string; initial?: boolean } + | { kind: 'order'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] } + +interface TaskSurfaceOption { id: string; label: string; detail?: string } +``` + +渲染器控制字体排印、间距、响应式布局、焦点顺序、键盘行为和主题 token。`grid` 是布局提示:可用宽度无法容纳所要求的列数时,渲染器会将其折叠。Markdown 使用产品支持的 Markdown 子集。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。 + +版本 1 有意不支持条件字段、客户端数据获取、图表、文件上传和任意事件处理器。新增任何块或字段类型都属于协议变更,必须在同一变更中加入解析器、渲染器、无障碍行为、回退方式和回放 fixture(测试前置数据)。 + +Task Surface 服务通过受 schema 校验的配置定义限制。初始默认值为:规范化模型不超过 64 KiB、块不超过 64 个、字段不超过 32 个、表格行不超过 200 行、提交内容不超过 32 KiB。模型内的 ID 必须唯一;字段值必须符合其声明;未知字段会被拒绝。这些限制约束日志、DOM 和提示词成本,但不改变协议。 + +## 工具与呈现契约 + +`show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface,则拒绝调用;否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }`。`presentationMeta` 持久化 `value.model`,使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface,并说明客户端无法渲染面板时,可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。 + +工具定义设置 `exclusive: true`,并且只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 + +根据[规范工具输出契约](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: + +```ts ignore-check +interface TaskSurfacePresentationMeta { + kind: 'dsh/task-surface' + version: 1 + surfaceId: string + model: TaskSurfaceModelV1 +} +``` + +该工具保留通用 [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)。带 key 的 Web 行读取 `ToolResultNode` 上已经保留的带标签元数据,无需新增 render-intent 分支或呈现注册表。不支持 Task Surface 的客户端会渲染普通结果内容。 + +Web 插件遵循 [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) 和 [slot 注册](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md)契约,为 `show_task_surface` 静态注册一个带 key 的 `conversation.chat.toolview` 条目。结算后,该行显示简洁摘要,并在行内展开声明式面板。模型不能选择会话标签页、详情栏、模态框、像素位置或 z-index。以后即使改变放置位置,也只是渲染器的决策,不会改变日志中记录的模型。 + +## 提交契约 + +Task Surface 领域通过 Host 传输层公开三个操作。只有 `submit` 会接纳用户消息: + +```ts ignore-check +type TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } +type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } + +interface TaskSurfaceService { + getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + submit(input: SubmitTaskSurfaceRequest): Promise + dismiss(input: DismissTaskSurfaceRequest): Promise +} + +interface SubmitTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + submissionId: TaskSurfaceSubmissionId + values: Record + note?: string +} + +type SubmitTaskSurfaceResult = + | { accepted: true; messageId: MessageId } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + +type GetActiveTaskSurfaceResult = + | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { active: false; reason: 'not-open' } + +interface DismissTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + dismissalId: TaskSurfaceDismissalId +} + +type DismissTaskSurfaceResult = + | { dismissed: true; eventSeq: number } + | { dismissed: false; reason: 'not-open' | 'stale' } +``` + +Host 解析出 `show_task_surface` 的确切成功调用实例,依据其已持久化模型重新校验提交值,并通过普通会话队列接纳响应。该响应成为一条用户角色消息,并使用可合并扩展的消息来源: + +```ts ignore-check +interface TaskSurfaceCorrelation { + version: 1 + submissionId: TaskSurfaceSubmissionId + callId: CallId + surfaceId: string + values: Record +} + +interface TaskSurfaceUserMessageSource { + kind: 'user' + rpcId: RpcId + taskSurface: TaskSurfaceCorrelation +} +``` + +浏览器安全的领域包拥有 `TaskSurfaceCorrelation` 及其带品牌类型的 `submissionId`。ApiProxy 拥有传输扩展,负责将其与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 + +产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。`taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` 追加一个 `task-surface/dismissed` 会话事件,但不启动轮次;该精确事件会关闭投影并更新 transcript(文本记录)中的对应行。重试会复用 `dismissalId` 并返回原始结果,不会再追加一个事件。 + +客户端边界上的提交具有事务性。接纳进行期间,面板会禁用提交;只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId`;对于一个已接受的 Surface,Host 只会接纳一条用户消息。 + +队列接纳与 `user/message` 持久化之间存在一个短暂区间。因此,通用排队消息 DTO 会保留 `Message.source`。带有匹配 Task Surface 关联信息的排队消息会使面板维持禁用状态;如果该队列项被丢弃,待处理状态会清除,草稿恢复为可编辑状态。在同一区间,Host 会持有一个进程内 single-flight 占用,并在消息提交持久化、接纳被拒或队列项被丢弃时释放。队列属于协调状态,并不是第二份持久生命周期记录。 + +## 生命周期与恢复 + +会话日志是真源。现有[会话投影系统](../architecture/2026-07-27-session-projection-and-command-log.md)中的一个小型 `taskSurface` 单元会折叠成功调用的 Surface 结果元数据和后续用户消息来源,得到以下状态: + +```ts ignore-check +interface TaskSurfaceProjection { + active: { callId: CallId; surfaceId: string } | null +} +``` + +一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它;匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例,不会使用独立的 Surface 数据库。 + +完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。当该结果超出已加载的历史窗口时,`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验元数据后返回 `{ callId, surfaceId, model }`。调用实例不存在或已经关闭时返回 `not-open`。因此,刷新和重新连接不要求活动结果位于历史尾段,也无需把模型复制到每一个投影基线中。 + +Web 插件将未提交值保存在一个有界、按会话持久化的 slot store 中,并以 `surfaceId` 为 key;这些值永远不会进入会话日志、提示词或长期记忆。已提交值存放在接纳的用户消息中,因此即使浏览器草稿丢失,也不会抹去结论。 + +## 包边界与依赖 + +该能力按职责变化处分包: + +| 包 | 职责 | +|---|---| +| `packages/task-surface/task-surface` | 浏览器安全的模型/类型和关联信息、解析器、限制、提交校验器/格式化器、会话事件扩展、投影单元,以及 Host 服务契约 | +| `packages/task-surface/tool-task-surface` | `show_task_surface`、规范输出、呈现元数据、通用 render intent、活动 Surface 检查和 `concludeTurn()` 行为 | +| `packages/client/ui-task-surface` | 静态带 key 的工具行、声明式 Web 渲染器、按会话划分的草稿 store,以及提交客户端 | +| `packages/host/apiproxy` | 类型化的活动 Surface 读取/提交/关闭传输、用户消息来源扩展和排队来源传递;将校验与接纳委托给 Task Surface 服务 | + +该实现依赖现有的消息日志、规范工具输出、带标签的 render intent、会话投影、按会话作用域声明的 slot store 和 slot 生命周期,不依赖在运行时创建客户端插件。Generated Client Plugin 工作流可以使用 Task Surface 展示审阅表单,但两个协议都不拥有或激活另一个协议。 + +## 交付阶段 + +1. 实现模型/解析器、投影单元、`show_task_surface`、呈现元数据、静态 Web 行,以及带只读块的通用回退。 +2. 增加字段、持久化草稿、经 Host 校验的提交/关闭、排队来源传递,以及可见用户消息接纳。 +3. 只增加有实际任务依据,并且拥有至少两个消费方或明确通用回退的组件类型。一个单独的显式用户操作可以启动生成式插件编写工作流,但只会创建候选项,绝不会直接推广代码。 + +## 考虑过的替代方案 + +**增加产品专用触发方式和面板。**不予采用,因为每种新任务形态都会把 agent 行为与已发布的产品组件耦合。产品代码应当定义一套接纳的组件词汇和放置策略;agent 则显式地从中选择。 + +**从工具调用中渲染任意 HTML、CSS 或 JavaScript。**不予采用,因为这会把临时交互变成可执行的客户端插件代码,却不具备代码所需的构建、预览、评估、批准或回滚生命周期。 + +**使用大型表单扩展 `userInteraction.ask()`。**本契约不采用这种做法。`ask()` 是一种阻塞式请求/响应操作,适用于正在运行的工具必须先获得简短答案才能继续执行的情况。Task Surface 会结束当前轮次,可以在刷新后继续保持打开,并把结果提交为下一条可见用户消息。 + +**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。单个静态带 key 的 toolview 会将调用实例数据保留在归属它的已记录调用中。 + +**只在规范工具值中保留模型。**不予采用,因为规范值不会持久化。回放要求将规范化模型写入 `presentationMeta`。 + +**将面板存入长期记忆。**不予采用,因为布局和草稿状态不是可复用事实。现有记忆策略可以保留用户提交的结论。 + +## 验收标准 + +- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。 +- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持对确切调用实例的关联;关闭操作记录一条日志事件,且不启动轮次。 +- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型,任何面板都不会泄漏到其他会话。 +- 不受支持的版本、格式错误的元数据以及客户端能力缺失时,系统回退到带普通消息绕过路径的可读工具结果内容;嵌套调用以及已有另一个活动 Surface 时发起的调用都无法打开 Surface,并以失败结束。 +- 解析器会在面板可交互前强制校验 ID、联合类型形态、字段值以及配置的字节数和数量限制。 +- 组件测试覆盖纯键盘操作、焦点恢复、无障碍名称、窄屏布局、两种主题,以及中英文产品界面。 +- 无密钥浏览器组合测试覆盖显示、编辑、接纳被拒后的重试、排队/丢弃提交、持久提交、关闭、刷新恢复和双重提交幂等性。 +- 前缀快照表明:无论任务特定模型如何变化,都只存在一个稳定的工具定义;只有调用参数和后续用户结论发生变化。 +- 卸载 Web 插件时,其所属 Fiber 会对工具行和草稿 store 执行 dispose(资源释放),但不会改变持久 transcript。 + +## 风险 + +第一批组件可能小到无法满足实际任务,也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定;v1 不提供表达式语言或网络行为。 + +即使设置了字节限制,大型表格和 Markdown 仍可能生成开销较高的 DOM。渲染器必须按需虚拟化或截断内容,同时保留可读回退和明确计数。 + +填写字段较多时,由产品格式化的提交消息可能过长。格式化器需要使用确定性的紧凑格式,保留每一个提交值,同时避免重复完整显示模型。 + +浏览器本地持久化的草稿可能保留敏感的未提交文本。store 需要遵守规定的字节上限、使用按会话划分的 key、在提交成功后显式清除,并采用与现有会话草稿相同的存储策略。 From 652f3b61bfa8124f3115a4827624ee8936f3c8a7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:56:18 -0700 Subject: [PATCH 004/262] docs: tighten Task Surface lifecycle contract --- .../feature/2026-08-04-task-surface.i18n.yaml | 4 +- .../feature/2026-08-04-task-surface.md | 127 ++++++++++++----- .../feature/2026-08-04-task-surface.zh.md | 129 +++++++++++++----- 3 files changed, 187 insertions(+), 73 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 36e7d8c5d6..fec93e2c36 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.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/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: 780d5d884c83ab47535fff95efcd80b0b7f0181f -2026-08-04-task-surface.zh.md: 38e6787ee5a4d86261e79c66d99f81614bdb5871 +2026-08-04-task-surface.md: 0d79d7b830689a269d1aede937fba6aa647ea483 +2026-08-04-task-surface.zh.md: 7960c02dc3c112a8cd1ff626274dfac1f01f1bdb diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index 780d5d884c..0d79d7b830 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -44,11 +44,14 @@ interface TaskSurfaceModelV1 { interface TaskSurfaceSection { id: string title?: string - layout?: 'stack' | 'grid' - columns?: 2 | 3 + layout?: TaskSurfaceLayout blocks: TaskSurfaceBlock[] } +type TaskSurfaceLayout = + | { kind: 'stack' } + | { kind: 'grid'; columns: 2 | 3 } + type TaskSurfaceBlock = | { kind: 'markdown'; text: string } | { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] } @@ -66,7 +69,9 @@ type TaskSurfaceField = interface TaskSurfaceOption { id: string; label: string; detail?: string } ``` -The renderer controls typography, spacing, responsive layout, focus order, keyboard behavior, and theme tokens. `grid` is a layout hint: it collapses when the available width cannot support the requested columns. Markdown uses the product's supported Markdown subset. Unknown versions or union arms use the generic tool-result fallback instead of partial interpretation. +The renderer controls typography, spacing, responsive layout, focus order, keyboard behavior, and theme tokens. An absent layout means `stack`; a `grid` layout owns its column count and collapses when the available width cannot support it. Unknown versions or union arms use the generic tool-result fallback instead of partial interpretation. + +The `markdown` block reuses `MarkdownText` with an explicit model-URL policy. `MarkdownText` gains `remoteImages: 'render' | 'alt-only'`, preserving `render` as its ordinary default; Task Surface always passes `alt-only`, so image syntax renders only its alt text. Raw HTML and embedded media remain omitted, automatic link previews are absent, and no model-supplied URL is dereferenced without explicit user activation. Ordinary HTTP(S) links may still navigate when the user chooses them. Fixed application assets such as syntax-highlighting chunks remain under the product's normal loading policy. Version 1 deliberately omits conditional fields, client-side data fetching, charts, file uploads, and arbitrary event handlers. A new block or field kind is a protocol change with a parser, renderer, accessibility behavior, fallback, and replay fixture in the same change. @@ -76,62 +81,83 @@ Limits are schema-backed configuration on the Task Surface service. The initial `show_task_surface` accepts `{ model: TaskSurfaceModelV1 }`. The Host parses and normalizes the complete model, rejects the call when that Session already has an open Task Surface, mints `surfaceId`, and returns canonical `{ surfaceId, model }` with the normalized model. `presentationMeta` persists `value.model`, so the projector and executor cannot disagree about normalization. The Native result names the Surface and explains that an ordinary message bypasses it when the client cannot render the panel. The tool then calls `exec.concludeTurn()` so the agent does not continue past the requested human checkpoint. -The tool definition sets `exclusive: true`, and the tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because Code Mode dispatch is nested and cannot carry its presentation metadata to the outer result. +The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because Code Mode dispatch is nested and cannot carry its presentation metadata to the outer result. -The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`: +The browser-safe domain package imports the type-only `Branded` primitive from `@deepseek-ai/dsh-brand` and owns all three Task Surface IDs. The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`: ```ts ignore-check +import type { Branded } from '@deepseek-ai/dsh-brand' + +type TaskSurfaceId = Branded<'TaskSurfaceId'> +type TaskSurfaceSubmissionId = Branded<'TaskSurfaceSubmissionId'> +type TaskSurfaceDismissalId = Branded<'TaskSurfaceDismissalId'> + interface TaskSurfacePresentationMeta { kind: 'dsh/task-surface' version: 1 - surfaceId: string + surfaceId: TaskSurfaceId model: TaskSurfaceModelV1 } ``` The tool keeps a generic [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md). The keyed Web row reads the tagged metadata already retained on `ToolResultNode`; no new render-intent arm or presentation registry is required. Clients without Task Surface support render the ordinary result content. -The Web plugin statically registers one keyed `conversation.chat.toolview` entry for `show_task_surface`, following the [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) and [slot registration](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md) contracts. The row renders a compact summary when settled and expands the declarative panel inline. The model does not choose a conversation tab, details column, modal, pixel position, or z-index. A later placement change remains a renderer decision and does not alter logged models. +The Web plugin has two static Session-scoped registrations under the [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) and [slot registration](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md) contracts. A keyed `conversation.chat.toolview` entry for `show_task_surface` renders the durable transcript occurrence as a compact summary and read-only replay. One `TaskSurfaceDock` entry in the existing `conversation.input.dock` is the only actionable mount: it reads the active projection, calls `getActive` for the exact identity, and owns fields, drafts, submit, and dismiss. Because the Dock is independent of transcript pagination, an active Surface remains actionable when its `ToolResultNode` is outside the loaded history window. + +The Dock follows the existing composer-chain fallback semantics. Any `conversation.composer` takeover hides the fallback composer stack, including `TaskSurfaceDock`, without unmounting it; the same draft owner reappears when the takeover resolves. A takeover does not receive Task Surface actions or create another editor. + +The model does not choose a conversation tab, dock order, details column, modal, pixel position, or z-index. A later placement change remains a renderer decision and does not alter logged models. The transcript row never becomes a second editor, so one Surface cannot acquire competing draft or submission owners. ## Submission contract The Task Surface domain exposes three operations through the Host transport. `submit` is the only one that admits a user message: ```ts ignore-check -type TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } -type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } +type TaskSurfaceSubmissionPhase = 'queued' | 'claiming' + +interface TaskSurfacePendingSubmission { + submissionId: TaskSurfaceSubmissionId + messageId: MessageId + phase: TaskSurfaceSubmissionPhase +} interface TaskSurfaceService { - getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + getActive(input: { sessionId: SessionId; surfaceId: TaskSurfaceId }): Promise submit(input: SubmitTaskSurfaceRequest): Promise dismiss(input: DismissTaskSurfaceRequest): Promise } interface SubmitTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId submissionId: TaskSurfaceSubmissionId values: Record note?: string } type SubmitTaskSurfaceResult = - | { accepted: true; messageId: MessageId } - | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + | { accepted: true; messageId: MessageId; phase: 'queued' } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' | 'submission-pending' } type GetActiveTaskSurfaceResult = - | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { + active: true + callId: CallId + surfaceId: TaskSurfaceId + model: TaskSurfaceModelV1 + pending: TaskSurfacePendingSubmission | null + } | { active: false; reason: 'not-open' } interface DismissTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId dismissalId: TaskSurfaceDismissalId } type DismissTaskSurfaceResult = | { dismissed: true; eventSeq: number } - | { dismissed: false; reason: 'not-open' | 'stale' } + | { dismissed: false; reason: 'not-open' | 'stale' | 'submission-pending' } ``` The Host resolves the exact successful `show_task_surface` occurrence, revalidates the submitted values against its persisted model, and admits the response through the normal Session queue. The response becomes a user-role message with a merge-extensible source: @@ -141,7 +167,7 @@ interface TaskSurfaceCorrelation { version: 1 submissionId: TaskSurfaceSubmissionId callId: CallId - surfaceId: string + surfaceId: TaskSurfaceId values: Record } @@ -152,13 +178,31 @@ interface TaskSurfaceUserMessageSource { } ``` -The browser-safe domain package owns `TaskSurfaceCorrelation` and its branded `submissionId`. ApiProxy owns the transport augmentation that combines it with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction. +The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation: -The product shell owns collapse and dismiss. Collapse is local view state and sends nothing. `taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` appends one `task-surface/dismissed` Session event and does not start a turn; the exact event closes the projection and updates the transcript row. Retries reuse `dismissalId` and return the original result without appending another event. +```ts ignore-check +interface QueuedMessage { + id: InboxItemId + messageId: MessageId + placement: 'queued' | 'steering' + source: MessageSource + content: readonly ContentBlock[] + preview: string + text: string | null +} +``` -Submission is transactional at the client boundary. The panel disables submit while admission is in flight and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId`; the Host admits one user message for one accepted Surface. +The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction. -There is a short interval between queue admission and the durable `user/message`. The generic queued-message DTO therefore retains `Message.source`. A queued message with matching Task Surface correlation keeps the panel disabled; if that queue item is discarded, the pending state clears and the draft becomes editable again. The Host holds a process-local single-flight claim for the same interval, then releases it on commit, rejection, or discard. The queue is coordination state, not a second durable lifecycle record. +The product shell owns collapse and dismiss. Collapse is local view state and sends nothing. When no submission is pending, `taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` appends one `task-surface/dismissed` Session event and does not start a turn; the exact event closes the projection and updates the Dock and transcript row. Retries reuse `dismissalId` and return the original result without appending another event. Dismiss is disabled while a submission is `queued` or `claiming`, and the Host rejects such a request with `submission-pending`. + +Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface. + +The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard. + +The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again. + +`session.updateQueue` rejects `edit` and `steer` for a Task Surface-correlated row. Editing would separate formatted content from its source-carried structured values, and steering would persist a `steering/message` that does not satisfy the submission lifecycle. `remove` is allowed while the row is queued; it reports the discard and restores the open Surface. Once claimed, the row has left the generic queue and queue mutations return `queue-item-not-found`. The Task Surface service holds one single-flight pending record until commit or discard. ## Lifecycle and recovery @@ -166,13 +210,13 @@ The Session log is the authority. A small `taskSurface` unit in the existing [Se ```ts ignore-check interface TaskSurfaceProjection { - active: { callId: CallId; surfaceId: string } | null + active: { callId: CallId; surfaceId: TaskSurfaceId } | null } ``` -One Session has at most one open Task Surface. A successful result opens it. A matching Task Surface user message or dismissal event closes it. A later ordinary user message also closes it as an explicit bypass; another `show_task_surface` call fails until one of those events closes the active occurrence. Rewind and fork derive their active occurrence by folding the resulting log; no separate Surface database participates. +One Session has at most one open Task Surface. A successful result opens it. A matching Task Surface user message or dismissal event closes it. A later ordinary user message also closes it as an explicit bypass; another `show_task_surface` call fails until one of those events closes the active occurrence. Rewind and fork derive their active occurrence by folding the resulting log; transient queue phase is not copied, and no separate Surface database participates. -The full model remains on its `tool/result.meta`; the projection carries only the active identity. When that result is outside the loaded history window, `taskSurface.getActive({ sessionId, surfaceId })` reads the exact occurrence from the Session log and returns `{ callId, surfaceId, model }` after revalidating the metadata. A missing or closed occurrence returns `not-open`. Refresh and reconnect therefore do not depend on the active result fitting in the history tail and do not duplicate the model into every projection baseline. +The full model remains on its `tool/result.meta`; the projection carries only the active identity. `TaskSurfaceDock` exists independently of history rows and reacts to that identity. `taskSurface.getActive({ sessionId, surfaceId })` reads the exact occurrence from the Session log, revalidates its metadata, joins the Task Surface service's pending coordination record, and returns `{ callId, surfaceId, model, pending }`. A missing or closed occurrence returns `not-open`. Refresh and reconnect therefore recover an actionable Surface and its same-process pending phase even when the result is outside the history tail, without copying the model into every projection baseline. The Web plugin keeps unsubmitted values in a bounded, per-Session persisted slot store keyed by `surfaceId`; they never enter the Session log, prompt, or long-term memory. Submitted values live in the accepted user message, so losing a browser draft cannot erase a conclusion. @@ -182,17 +226,22 @@ The capability is split where ownership changes: | Package | Responsibility | |---|---| -| `packages/task-surface/task-surface` | Browser-safe model/types and correlation, parser, limits, submission validator/formatter, Session event extension, projection unit, and Host service contract | +| `packages/core/agent` and `packages/core/agent-loop` | Generic terminal outcome for a claimed next-turn inbox occurrence, allowing a Host observer to distinguish durable admission from discard without Task Surface-specific types | +| `packages/task-surface/task-surface` | Browser-safe model, branded IDs, correlation and pending types, parser, limits, submission validator/formatter, Session event extension, projection unit, and Host service contract | | `packages/task-surface/tool-task-surface` | `show_task_surface`, canonical output, presentation metadata, generic render intent, active-Surface check, and `concludeTurn()` behavior | -| `packages/client/ui-task-surface` | Static keyed tool row, declarative Web renderer, per-Session draft store, and submit client | -| `packages/host/apiproxy` | Typed active-read/submit/dismiss transport, user-source augmentation, and queued-source carriage; delegates validation and admission to the Task Surface service | +| `packages/client/runtime` | Generic queued-message `source` projection and Session-scoped active-projection access | +| `packages/client/ui-primitives` | Task Surface-agnostic `MarkdownText.remoteImages` policy, including the `alt-only` image branch and URL-policy tests | +| `packages/client/ui-task-surface` | Static actionable `TaskSurfaceDock`, read-only keyed transcript row, declarative Web renderer that consumes the Task Surface model and `MarkdownText` in `alt-only` mode, per-Session draft store, and submit client | +| `packages/host/apiproxy` | Typed active-read/submit/dismiss transport, user-source augmentation and carriage, queue-action restrictions, and routing of claim and terminal outcomes; delegates validation, pending coordination, and admission to the Task Surface service | + +`ui-task-surface` depends on the browser-safe Task Surface domain, client connection and runtime, locale, `ui-conversation` for the declared slot contracts, `ui-slots` for registration, and `ui-primitives`; `ui-primitives` does not depend on Task Surface. ApiProxy depends on the Task Surface service contract and the generic AgentLoop terminal outcome. Core Agent packages do not import Task Surface types. The implementation depends on the existing message log, canonical tool output, tagged render intents, Session projection, per-Session declared slot stores, and slot lifecycle. It does not depend on runtime Client Plugin creation. The generated Client Plugin workflow may use Task Surface to present a review form, but neither protocol owns or activates the other. ## Delivery stages -1. Land the model/parser, projection unit, `show_task_surface`, presentation metadata, static Web row, and generic fallback with read-only blocks. -2. Add fields, persisted drafts, Host-validated submit/dismiss, queued-source carriage, and visible user-message admission. +1. Land the model/parser, `MarkdownText` model-URL policy, projection unit, `show_task_surface`, presentation metadata, read-only Web row, static `TaskSurfaceDock`, active retrieval, and generic fallback with read-only blocks. +2. Add fields, persisted drafts, Host-validated submit/dismiss, branded correlation, client queued-source carriage, Task Surface `queued`/`claiming` coordination, claimed-occurrence terminal reporting, queue-action restrictions, and visible user-message admission. 3. Add only component kinds justified by real tasks and two consumers or a clear generic fallback. A separate explicit user action may start the generated Plugin authoring workflow, but it creates a candidate; it never promotes code directly. ## Alternatives considered @@ -203,7 +252,7 @@ The implementation depends on the existing message log, canonical tool output, t **Extend `userInteraction.ask()` with a large form.** Rejected for this contract. `ask()` is a blocking request/response operation used when a running tool cannot continue without a short answer. A Task Surface ends the turn, may remain open across refreshes, and submits its result as the next visible user turn. -**Register one dynamic `conversation.view` per call.** Rejected because the view ledger is global while its render scope is per Session, and because transient task identity would become registration identity. One static keyed toolview keeps occurrence data in the logged call where it belongs. +**Register one dynamic `conversation.view` per call.** Rejected because the view ledger is global while its render scope is per Session, and because transient task identity would become registration identity. One static Session-scoped Dock owns interaction, and one static keyed row summarizes the logged occurrence; neither registration uses occurrence identity. **Keep the model only in the canonical tool value.** Rejected because canonical values are not persisted. Replay requires the normalized model in `presentationMeta`. @@ -212,21 +261,29 @@ The implementation depends on the existing message log, canonical tool output, t ## Acceptance criteria - A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `code`-only mode does not advertise it. -- Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. -- Refresh, reconnect, Session switching, fork, and rewind produce the lifecycle state implied by the log; `getActive` recovers a model outside the history tail, and no panel leaks across Sessions. +- The static `TaskSurfaceDock` is the only editor and remains actionable for an active result outside the loaded history window; the keyed toolview remains a read-only transcript summary and replay. A composer takeover hides the still-mounted Dock, preserves its draft, and reveals the same owner after release. +- Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact branded occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. +- The queued client row retains the correlated message source. `getActive` exposes `queued` or `claiming` across same-process reconnect; commit closes the projection, while explicit discard clears pending state and leaves the Surface open. Queue-row disappearance alone changes no UI state. Edit and steer are rejected, and remove succeeds only before claim. +- Refresh, reconnect, Session switching, fork, and rewind produce the lifecycle state implied by the log; `getActive` recovers the model and pending phase outside the history tail, and no panel, pending state, or draft leaks across Sessions. - Unsupported versions, malformed metadata, and absent client capability fall back to readable tool-result content with the ordinary-message bypass; nested calls and calls made while another Surface is active fail without opening a Surface. -- The parser enforces IDs, union shapes, field values, and configured byte/count limits before the panel becomes actionable. +- Wire schemas validate ID strings and domain APIs expose the branded ID types throughout. The model parser enforces tagged layout shapes, field values, and configured byte/count limits before the panel becomes actionable. Browser tests show image syntax becomes alt text, raw HTML and embedded media do not render, and no model-supplied URL is requested before explicit user activation. - Keyboard-only operation, focus restoration, accessible names, narrow layouts, both themes, and zh/en product chrome are covered by component tests. -- Keyless browser composition covers show, edit, retry after rejected admission, queued/discarded submission, durable submit, dismiss, refresh recovery, and double-submit idempotency. +- Keyless browser composition covers show, Dock and read-only-row ownership, off-window recovery, edit, retry after rejected admission, queued-to-claiming transition, discard, durable handoff without an editable gap, forbidden queue actions, dismiss, reconnect, and double-submit idempotency. - Prefix snapshots show one stable tool definition regardless of the task-specific model; only the call arguments and later user conclusion vary. -- Unloading the Web plugin disposes its row and draft stores through the owning Fiber without changing the durable transcript. +- Unloading the Web plugin disposes its Dock, row, and draft stores through the owning Fiber without changing the durable transcript. ## Risks The first component set may be either too small for useful tasks or broad enough to become a weak application framework. Usage evidence should decide additions; v1 has no expression language or network behavior. +The Task Surface Markdown policy gives up inline images, media, and automatic link previews. Ordinary links remain useful, but only an explicit user activation may navigate or start a request. + Large tables and Markdown can still create expensive DOM even inside byte limits. The renderer must virtualize or truncate where needed while preserving a readable fallback and explicit counts. A product-formatted submission can become verbose when many fields are filled. The formatter needs a deterministic compact form and must preserve every submitted value without repeating the complete display model. +Holding a process-local claim until durable handoff adds a terminal-state invariant. Every admission exit must produce either the matching `user/message` or an explicit discard; otherwise a reconnect could retain a disabled Dock indefinitely. + Browser-local draft persistence can retain sensitive unsubmitted text. The store needs the stated byte bound, per-Session keys, explicit clearing after acceptance, and the same storage posture as the existing conversation draft. + +The Dock and transcript row show the same occurrence in different roles. Keeping the row read-only and the Dock as the sole mutation owner prevents conflicting drafts at the cost of a second compact representation while the Surface is active. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index 38e6787ee5..7960c02dc3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -44,11 +44,14 @@ interface TaskSurfaceModelV1 { interface TaskSurfaceSection { id: string title?: string - layout?: 'stack' | 'grid' - columns?: 2 | 3 + layout?: TaskSurfaceLayout blocks: TaskSurfaceBlock[] } +type TaskSurfaceLayout = + | { kind: 'stack' } + | { kind: 'grid'; columns: 2 | 3 } + type TaskSurfaceBlock = | { kind: 'markdown'; text: string } | { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] } @@ -66,7 +69,9 @@ type TaskSurfaceField = interface TaskSurfaceOption { id: string; label: string; detail?: string } ``` -渲染器控制字体排印、间距、响应式布局、焦点顺序、键盘行为和主题 token。`grid` 是布局提示:可用宽度无法容纳所要求的列数时,渲染器会将其折叠。Markdown 使用产品支持的 Markdown 子集。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。 +渲染器控制字体排印、间距、响应式布局、焦点顺序、键盘行为和主题 token。未指定布局时使用 `stack`;`grid` 布局自带列数,可用宽度无法容纳时会折叠。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。 + +`markdown` 块复用 `MarkdownText`,并显式指定模型 URL 策略。`MarkdownText` 新增 `remoteImages: 'render' | 'alt-only'`,普通场景仍默认使用 `render`;Task Surface 始终传入 `alt-only`,因此图片语法只渲染替代文本。原始 HTML 和嵌入式媒体仍会被省略,不生成自动链接预览;未经用户显式操作,不会解引用模型提供的任何 URL。普通 HTTP(S) 链接仍可在用户选择后导航。语法高亮分片等固定应用资源继续遵循产品的常规加载策略。 版本 1 有意不支持条件字段、客户端数据获取、图表、文件上传和任意事件处理器。新增任何块或字段类型都属于协议变更,必须在同一变更中加入解析器、渲染器、无障碍行为、回退方式和回放 fixture(测试前置数据)。 @@ -76,62 +81,83 @@ Task Surface 服务通过受 schema 校验的配置定义限制。初始默认 `show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface,则拒绝调用;否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }`。`presentationMeta` 持久化 `value.model`,使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface,并说明客户端无法渲染面板时,可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。 -工具定义设置 `exclusive: true`,并且只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 +工具定义省略 `isConcurrencySafe`。根据现有工具注册表契约,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 -根据[规范工具输出契约](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: +浏览器安全的领域包从 `@deepseek-ai/dsh-brand` 以仅类型方式导入 `Branded` 原语,并拥有全部三个 Task Surface ID。根据[规范工具输出契约](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: ```ts ignore-check +import type { Branded } from '@deepseek-ai/dsh-brand' + +type TaskSurfaceId = Branded<'TaskSurfaceId'> +type TaskSurfaceSubmissionId = Branded<'TaskSurfaceSubmissionId'> +type TaskSurfaceDismissalId = Branded<'TaskSurfaceDismissalId'> + interface TaskSurfacePresentationMeta { kind: 'dsh/task-surface' version: 1 - surfaceId: string + surfaceId: TaskSurfaceId model: TaskSurfaceModelV1 } ``` 该工具保留通用 [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)。带 key 的 Web 行读取 `ToolResultNode` 上已经保留的带标签元数据,无需新增 render-intent 分支或呈现注册表。不支持 Task Surface 的客户端会渲染普通结果内容。 -Web 插件遵循 [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) 和 [slot 注册](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md)契约,为 `show_task_surface` 静态注册一个带 key 的 `conversation.chat.toolview` 条目。结算后,该行显示简洁摘要,并在行内展开声明式面板。模型不能选择会话标签页、详情栏、模态框、像素位置或 z-index。以后即使改变放置位置,也只是渲染器的决策,不会改变日志中记录的模型。 +Web 插件按照 [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) 和 [slot 注册](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md)契约,提供两个静态的会话作用域注册项。一个以 `show_task_surface` 为 key 的 `conversation.chat.toolview` 条目将持久 transcript(文本记录)调用实例渲染为简洁摘要和只读回放。现有 `conversation.input.dock` 中的一个 `TaskSurfaceDock` 条目是唯一可操作的挂载点:它读取活动投影,针对确切身份调用 `getActive`,并拥有字段、草稿、提交和关闭操作。Dock 与 transcript 分页相互独立,因此即使 `ToolResultNode` 位于已加载历史窗口之外,活动 Surface 仍可操作。 + +Dock 遵循现有 composer chain 的回退语义。任何 `conversation.composer` 接管都会隐藏包括 `TaskSurfaceDock` 在内的回退 composer 栈,但不会将其卸载;接管结束后,同一个草稿所有者会重新出现。接管方不会获得 Task Surface 操作,也不会创建另一个编辑器。 + +模型不能选择会话标签页、Dock 顺序、详情栏、模态框、像素位置或 z-index。以后即使改变放置位置,也只是渲染器的决策,不会改变日志中记录的模型。transcript 行绝不会成为第二个编辑器,因此同一个 Surface 不会出现相互竞争的草稿或提交所有者。 ## 提交契约 Task Surface 领域通过 Host 传输层公开三个操作。只有 `submit` 会接纳用户消息: ```ts ignore-check -type TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } -type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } +type TaskSurfaceSubmissionPhase = 'queued' | 'claiming' + +interface TaskSurfacePendingSubmission { + submissionId: TaskSurfaceSubmissionId + messageId: MessageId + phase: TaskSurfaceSubmissionPhase +} interface TaskSurfaceService { - getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + getActive(input: { sessionId: SessionId; surfaceId: TaskSurfaceId }): Promise submit(input: SubmitTaskSurfaceRequest): Promise dismiss(input: DismissTaskSurfaceRequest): Promise } interface SubmitTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId submissionId: TaskSurfaceSubmissionId values: Record note?: string } type SubmitTaskSurfaceResult = - | { accepted: true; messageId: MessageId } - | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + | { accepted: true; messageId: MessageId; phase: 'queued' } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' | 'submission-pending' } type GetActiveTaskSurfaceResult = - | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { + active: true + callId: CallId + surfaceId: TaskSurfaceId + model: TaskSurfaceModelV1 + pending: TaskSurfacePendingSubmission | null + } | { active: false; reason: 'not-open' } interface DismissTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId dismissalId: TaskSurfaceDismissalId } type DismissTaskSurfaceResult = | { dismissed: true; eventSeq: number } - | { dismissed: false; reason: 'not-open' | 'stale' } + | { dismissed: false; reason: 'not-open' | 'stale' | 'submission-pending' } ``` Host 解析出 `show_task_surface` 的确切成功调用实例,依据其已持久化模型重新校验提交值,并通过普通会话队列接纳响应。该响应成为一条用户角色消息,并使用可合并扩展的消息来源: @@ -141,7 +167,7 @@ interface TaskSurfaceCorrelation { version: 1 submissionId: TaskSurfaceSubmissionId callId: CallId - surfaceId: string + surfaceId: TaskSurfaceId values: Record } @@ -152,13 +178,31 @@ interface TaskSurfaceUserMessageSource { } ``` -浏览器安全的领域包拥有 `TaskSurfaceCorrelation` 及其带品牌类型的 `submissionId`。ApiProxy 拥有传输扩展,负责将其与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 +`session/queue` 线上的条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息: -产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。`taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` 追加一个 `task-surface/dismissed` 会话事件,但不启动轮次;该精确事件会关闭投影并更新 transcript(文本记录)中的对应行。重试会复用 `dismissalId` 并返回原始结果,不会再追加一个事件。 +```ts ignore-check +interface QueuedMessage { + id: InboxItemId + messageId: MessageId + placement: 'queued' | 'steering' + source: MessageSource + content: readonly ContentBlock[] + preview: string + text: string | null +} +``` -客户端边界上的提交具有事务性。接纳进行期间,面板会禁用提交;只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId`;对于一个已接受的 Surface,Host 只会接纳一条用户消息。 +浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 -队列接纳与 `user/message` 持久化之间存在一个短暂区间。因此,通用排队消息 DTO 会保留 `Message.source`。带有匹配 Task Surface 关联信息的排队消息会使面板维持禁用状态;如果该队列项被丢弃,待处理状态会清除,草稿恢复为可编辑状态。在同一区间,Host 会持有一个进程内 single-flight 占用,并在消息提交持久化、接纳被拒或队列项被丢弃时释放。队列属于协调状态,并不是第二份持久生命周期记录。 +产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。没有待处理提交时,`taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` 会追加一个 `task-surface/dismissed` 会话事件,但不启动轮次;该精确事件会关闭投影,并更新 Dock 和 transcript 行。重试会复用 `dismissalId` 并返回原始结果,不会再追加事件。提交处于 `queued` 或 `claiming` 阶段时,关闭操作会被禁用,Host 也会以 `submission-pending` 拒绝这类请求。 + +客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中,Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。 + +Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。 + +匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose(资源释放)时,系统会报告丢弃、清除认领状态,并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive`:`pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态,不是第二个持久权威来源;Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。 + +对于带有 Task Surface 关联信息的行,`session.updateQueue` 会拒绝 `edit` 和 `steer`。编辑会让格式化内容与消息来源所携带的结构化值脱节,而 steering(中途引导)会持久化一条不符合提交生命周期的 `steering/message`。该行仍在队列中时允许 `remove`;它会报告丢弃并恢复为打开的 Surface。行被认领后即已离开通用队列,队列变更会返回 `queue-item-not-found`。Task Surface 服务会持有一份 single-flight 待处理记录,直至提交或丢弃。 ## 生命周期与恢复 @@ -166,33 +210,38 @@ interface TaskSurfaceUserMessageSource { ```ts ignore-check interface TaskSurfaceProjection { - active: { callId: CallId; surfaceId: string } | null + active: { callId: CallId; surfaceId: TaskSurfaceId } | null } ``` -一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它;匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例,不会使用独立的 Surface 数据库。 +一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它;匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例;瞬态队列阶段不会被复制,也不会有独立的 Surface 数据库参与其中。 -完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。当该结果超出已加载的历史窗口时,`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验元数据后返回 `{ callId, surfaceId, model }`。调用实例不存在或已经关闭时返回 `not-open`。因此,刷新和重新连接不要求活动结果位于历史尾段,也无需把模型复制到每一个投影基线中。 +完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。`TaskSurfaceDock` 独立于历史行存在,并会响应该身份。`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验其元数据,合并 Task Surface 服务的待处理协调记录,并返回 `{ callId, surfaceId, model, pending }`。调用实例不存在或已经关闭时返回 `not-open`。因此,即使结果位于历史尾段之外,刷新和重新连接仍能恢复可操作的 Surface 及其同进程待处理阶段,而无需把模型复制到每一个投影基线中。 Web 插件将未提交值保存在一个有界、按会话持久化的 slot store 中,并以 `surfaceId` 为 key;这些值永远不会进入会话日志、提示词或长期记忆。已提交值存放在接纳的用户消息中,因此即使浏览器草稿丢失,也不会抹去结论。 ## 包边界与依赖 -该能力按职责变化处分包: +该能力在职责变化处拆分为多个包: | 包 | 职责 | |---|---| -| `packages/task-surface/task-surface` | 浏览器安全的模型/类型和关联信息、解析器、限制、提交校验器/格式化器、会话事件扩展、投影单元,以及 Host 服务契约 | +| `packages/core/agent` 和 `packages/core/agent-loop` | 为已认领的下一轮 inbox 调用实例提供通用终态结果,让 Host 观察方无需使用 Task Surface 专用类型,即可区分持久接纳和丢弃 | +| `packages/task-surface/task-surface` | 浏览器安全的模型、带品牌类型的 ID、关联和待处理类型、解析器、限制、提交校验器/格式化器、会话事件扩展、投影单元,以及 Host 服务契约 | | `packages/task-surface/tool-task-surface` | `show_task_surface`、规范输出、呈现元数据、通用 render intent、活动 Surface 检查和 `concludeTurn()` 行为 | -| `packages/client/ui-task-surface` | 静态带 key 的工具行、声明式 Web 渲染器、按会话划分的草稿 store,以及提交客户端 | -| `packages/host/apiproxy` | 类型化的活动 Surface 读取/提交/关闭传输、用户消息来源扩展和排队来源传递;将校验与接纳委托给 Task Surface 服务 | +| `packages/client/runtime` | 通用排队消息 `source` 投影和会话作用域的活动投影访问 | +| `packages/client/ui-primitives` | 与 Task Surface 无关的 `MarkdownText.remoteImages` 策略,包括 `alt-only` 图片分支和 URL 策略测试 | +| `packages/client/ui-task-surface` | 静态且可操作的 `TaskSurfaceDock`、带 key 的只读 transcript 行、消费 Task Surface 模型并以 `alt-only` 模式使用 `MarkdownText` 的声明式 Web 渲染器、按会话划分的草稿 store,以及提交客户端 | +| `packages/host/apiproxy` | 类型化的活动 Surface 读取/提交/关闭传输、用户消息来源扩展与传递、队列操作限制,以及认领和终态结果的路由;将校验、待处理协调和接纳委托给 Task Surface 服务 | + +`ui-task-surface` 依赖浏览器安全的 Task Surface 领域包、客户端连接与运行时、locale、`ui-conversation` 所声明的 slot 契约、用于注册的 `ui-slots`,以及 `ui-primitives`;`ui-primitives` 不反向依赖 Task Surface。ApiProxy 依赖 Task Surface 服务契约和通用 AgentLoop 终态结果。核心 Agent 包不导入 Task Surface 类型。 该实现依赖现有的消息日志、规范工具输出、带标签的 render intent、会话投影、按会话作用域声明的 slot store 和 slot 生命周期,不依赖在运行时创建客户端插件。Generated Client Plugin 工作流可以使用 Task Surface 展示审阅表单,但两个协议都不拥有或激活另一个协议。 ## 交付阶段 -1. 实现模型/解析器、投影单元、`show_task_surface`、呈现元数据、静态 Web 行,以及带只读块的通用回退。 -2. 增加字段、持久化草稿、经 Host 校验的提交/关闭、排队来源传递,以及可见用户消息接纳。 +1. 实现模型/解析器、`MarkdownText` 模型 URL 策略、投影单元、`show_task_surface`、呈现元数据、只读 Web 行、静态 `TaskSurfaceDock`、活动 Surface 读取,以及带只读块的通用回退。 +2. 增加字段、持久化草稿、经 Host 校验的提交/关闭、带品牌类型的关联信息、客户端排队来源传递、Task Surface `queued`/`claiming` 协调、已认领调用实例的终态报告、队列操作限制,以及可见用户消息接纳。 3. 只增加有实际任务依据,并且拥有至少两个消费方或明确通用回退的组件类型。一个单独的显式用户操作可以启动生成式插件编写工作流,但只会创建候选项,绝不会直接推广代码。 ## 考虑过的替代方案 @@ -203,7 +252,7 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor **使用大型表单扩展 `userInteraction.ask()`。**本契约不采用这种做法。`ask()` 是一种阻塞式请求/响应操作,适用于正在运行的工具必须先获得简短答案才能继续执行的情况。Task Surface 会结束当前轮次,可以在刷新后继续保持打开,并把结果提交为下一条可见用户消息。 -**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。单个静态带 key 的 toolview 会将调用实例数据保留在归属它的已记录调用中。 +**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。一个静态的会话作用域 Dock 负责交互,一个静态带 key 的行概述已记录的调用实例;两个注册项都不使用调用实例身份。 **只在规范工具值中保留模型。**不予采用,因为规范值不会持久化。回放要求将规范化模型写入 `presentationMeta`。 @@ -212,21 +261,29 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor ## 验收标准 - 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。 -- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持对确切调用实例的关联;关闭操作记录一条日志事件,且不启动轮次。 -- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型,任何面板都不会泄漏到其他会话。 +- 静态 `TaskSurfaceDock` 是唯一的编辑器,即使活动结果位于已加载历史窗口之外也仍可操作;带 key 的 toolview 始终是 transcript 的只读摘要和回放。composer 接管会隐藏仍处于挂载状态的 Dock、保留其草稿,并在接管释放后重新显示同一个所有者。 +- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持带品牌类型的确切调用实例关联;关闭操作记录一条日志事件,且不启动轮次。 +- 客户端排队行保留已关联的消息来源。`getActive` 可在同一进程的重新连接前后公开 `queued` 或 `claiming`;提交会关闭投影,显式丢弃则会清除待处理状态并让 Surface 保持打开。队列行消失本身不会改变任何 UI 状态。系统会拒绝编辑和 steering,且移除操作只能在认领前成功。 +- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型和待处理阶段,任何面板、待处理状态或草稿都不会泄漏到其他会话。 - 不受支持的版本、格式错误的元数据以及客户端能力缺失时,系统回退到带普通消息绕过路径的可读工具结果内容;嵌套调用以及已有另一个活动 Surface 时发起的调用都无法打开 Surface,并以失败结束。 -- 解析器会在面板可交互前强制校验 ID、联合类型形态、字段值以及配置的字节数和数量限制。 +- 线上的 schema 会校验 ID 字符串,领域 API 始终公开带品牌类型的 ID。模型解析器会在面板可交互前强制校验带标签的布局形态、字段值,以及配置的字节数和数量限制。浏览器测试证明:图片语法会变成替代文本,原始 HTML 和嵌入式媒体不会渲染,而且在用户显式操作前不会请求模型提供的 URL。 - 组件测试覆盖纯键盘操作、焦点恢复、无障碍名称、窄屏布局、两种主题,以及中英文产品界面。 -- 无密钥浏览器组合测试覆盖显示、编辑、接纳被拒后的重试、排队/丢弃提交、持久提交、关闭、刷新恢复和双重提交幂等性。 +- 无密钥浏览器组合测试覆盖显示、Dock 与只读行的职责归属、窗口外恢复、编辑、接纳被拒后的重试、从 `queued` 到 `claiming` 的转换、丢弃、没有可编辑空档的持久交接、禁止的队列操作、关闭、重新连接和双重提交幂等性。 - 前缀快照表明:无论任务特定模型如何变化,都只存在一个稳定的工具定义;只有调用参数和后续用户结论发生变化。 -- 卸载 Web 插件时,其所属 Fiber 会对工具行和草稿 store 执行 dispose(资源释放),但不会改变持久 transcript。 +- 卸载 Web 插件时,其所属 Fiber 会对 Dock、工具行和草稿 store 执行 dispose,但不会改变持久 transcript。 ## 风险 第一批组件可能小到无法满足实际任务,也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定;v1 不提供表达式语言或网络行为。 +Task Surface 的 Markdown 策略舍弃行内图片、媒体和自动链接预览。普通链接仍有用,但只有用户显式操作后,才可以导航或发起请求。 + 即使设置了字节限制,大型表格和 Markdown 仍可能生成开销较高的 DOM。渲染器必须按需虚拟化或截断内容,同时保留可读回退和明确计数。 填写字段较多时,由产品格式化的提交消息可能过长。格式化器需要使用确定性的紧凑格式,保留每一个提交值,同时避免重复完整显示模型。 +在完成持久交接之前一直持有进程内认领状态,会新增一项终态不变量。每条接纳退出路径都必须产生匹配的 `user/message` 或显式丢弃,否则重新连接可能会让 Dock 永久处于禁用状态。 + 浏览器本地持久化的草稿可能保留敏感的未提交文本。store 需要遵守规定的字节上限、使用按会话划分的 key、在提交成功后显式清除,并采用与现有会话草稿相同的存储策略。 + +Dock 和 transcript 行以不同角色展示同一个调用实例。将工具行保持为只读,并让 Dock 成为唯一的变更所有者,可以避免草稿冲突,但代价是 Surface 活动期间会出现第二份简洁表示。 From fe505e1e91ce7dcd2fda1c3c3a45eaa92abb2b60 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:02:10 -0700 Subject: [PATCH 005/262] feat(web): a prose mention of a produced file opens it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chatFileMentions service (provided by ui-deliverables beside its turn-tail entry, reached via ctx.get) resolves inline-code tokens in the closing message against the turn's produced locations: exact path or unique basename links, ambiguity and unknowns stay inert. MarkdownText gains the optional fileMentions seam — settled renders only, never inside anchors. --- ...6-08-07-web-inline-file-mentions.i18n.yaml | 6 + .../2026-08-07-web-inline-file-mentions.md | 28 +++ .../2026-08-07-web-inline-file-mentions.zh.md | 28 +++ apps/web/tests/produced-file-mentions.e2e.ts | 162 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 29 +++- .../src/client/chat/ChatView.tsx | 4 +- .../src/client/contract/slots.ts | 32 ++++ .../ui-conversation/src/client/index.ts | 1 + .../ui-conversation/tests/chat-view.spec.tsx | 41 +++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../src/client/ProducedFiles.tsx | 7 +- .../ui-deliverables/src/client/index.ts | 25 ++- .../src/client/turn-deliverables.ts | 43 +++++ .../tests/produced-files.spec.tsx | 47 ++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 2 +- .../src/markdown/MarkdownText.module.css | 20 +++ .../src/markdown/MarkdownText.tsx | 26 ++- .../ui-primitives/src/markdown/render.tsx | 51 +++++- .../tests/markdown-render-units.spec.tsx | 1 + .../ui-primitives/tests/markdown.spec.tsx | 43 +++++ tsconfig.host.json | 1 + 31 files changed, 584 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md create mode 100644 apps/web/tests/produced-file-mentions.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml new file mode 100644 index 0000000000..e8fe387234 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.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-07-web-inline-file-mentions.md +2026-08-07-web-inline-file-mentions.md: 79c6bddd500dc0b68bf64f3c4bea114ca41b4b62 +2026-08-07-web-inline-file-mentions.zh.md: ddc54d3b43570b19fb23c1c9c8079ef4ca65f8fa diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md new file mode 100644 index 0000000000..79c6bddd50 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -0,0 +1,28 @@ +# Agent Note: inline-code file mentions open the file they name + +Status: implemented + +English | [中文](2026-08-07-web-inline-file-mentions.zh.md) + +> Scope: linking inline-code tokens in the closing message's prose to the files the turn produced. Not in scope: recognizing paths in plain prose, linking files the turn did not produce, and mentions in streaming or mid-turn messages. + +## Problem + +The produced-files row lists a turn's output, but the closing message usually also *names* the file in prose — as inline code, like `` `deepseek-homepage.html` `` — and that mention was inert text. The reader's eye lands on the sentence first; the affordance sat one row below it. + +## Decision + +**A prose mention links only when it matches a produced file.** The [produced-files decision](2026-07-31-web-workspace-file-links.md) rejected linkifying the closing message because rendering must not depend on the model spelling a path recognizably; that holds. The row remains the authoritative, prose-independent account. This feature adds a second consumer of the same `locations` vocabulary: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path. A basename two paths share stays inert rather than guessing, and a token naming nothing the turn wrote stays inert — a mention link can never 404. + +**The renderer owns no vocabulary, and the provider is the deliverables plugin.** `MarkdownText` takes an optional `MarkdownFileMentions` resolver and consults it for inline-code tokens — after URL promotion, which wins, and never inside an anchor, where a button cannot nest. What names a file is decided behind the optional `chatFileMentions` service ui-conversation reaches via `ctx.get`: ui-deliverables provides it beside its turn-tail chain entry, so one cordis.yml line composes the row and the prose links in or out together, and ui-primitives gains no session concepts. Mentions apply to settled renders only — the streaming cache must not bake in handlers that could go stale, and the vocabulary is not final until the turn closes. The consumer memoizes the resolver on the closing seq rather than the growing transcript, so a settled message's cached parse survives stream appends. + +## Alternatives considered + +- **Path-shaped regex over all prose** — links `package.json` mentioned abstractly and examples that were never written; every false positive is a click that opens nothing or the wrong file. The vocabulary approach cannot produce a dead link. +- **Linking suffix matches (`out/index.html` mentioned as `index.html` in a subdirectory listing)** — deferred; exact path and unique basename cover the observed closing-message shapes, and a wider matcher can loosen later without breaking the seam. +- **Resolving in ui-primitives against a passed path list** — puts matching policy in the generic renderer, where other consumers would inherit it unasked. The resolver seam keeps policy with the owner. +- **Threading the vocabulary through the turn-tail chain** — the chain is a render dispatch below the message; mentions decorate markdown inside it, which only data reaching MarkdownText can do. The optional service is that data path, and its absence is the off state. + +## Consequences + +The mention and the row are two affordances for one fact, styled alike (underlined at rest, full path as `title`). `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md new file mode 100644 index 0000000000..ddc54d3b43 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -0,0 +1,28 @@ +# Agent Note:行内代码文件提及可打开其命名的文件 + +Status: implemented + +[English](2026-08-07-web-inline-file-mentions.md) | 中文 + +> 范围:把收尾消息正文中的行内代码 token 链接到本轮产出的文件。不在范围内:识别普通正文中的路径、链接本轮未产出的文件,以及流式或轮次中途消息里的提及。 + +## 问题 + +产物行列出了一轮的输出,但收尾消息通常也会在正文里*点名*文件——以行内代码形式,如 `` `deepseek-homepage.html` ``——而这个提及是死文本。读者的视线先落在句子上;可点击的交互却在下面一行。 + +## 决定 + +**正文提及只在与产出文件对得上时才成为链接。**[产物行的决定](2026-07-31-web-workspace-file-links.md)否决过"把收尾消息链接化",理由是渲染不能依赖模型把路径写得可识别;这一点不变。产物行仍是权威的、不依赖正文的记录。本特性只是给同一份 `locations` 词表增加第二个消费者:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析。两条路径共享的 basename 保持死文本而不猜测,命名了本轮没写过的文件的 token 同样保持死文本——提及链接永远不会 404。 + +**渲染器不持有词表,提供方是 deliverables 插件。**`MarkdownText` 接受可选的 `MarkdownFileMentions` 解析器,对行内代码 token 询问它——URL 提升优先于解析器,且绝不在锚点内部(按钮不能嵌套在链接里)。什么算文件名的决定藏在 ui-conversation 经 `ctx.get` 触达的可选 `chatFileMentions` service 背后:ui-deliverables 在其 turn-tail chain 注册项旁提供该 service,因此 cordis.yml 中的一行同时把产物行和正文链接组合进来或去掉,ui-primitives 不引入任何会话概念。提及只作用于已定稿的渲染——流式缓存不能烘进可能过期的 handler,而且词表在轮次收尾前并不最终。消费方按收尾 seq 而非不断增长的 transcript 记忆化解析器,因此已定稿消息的缓存解析在流式追加中得以保留。 + +## 考虑过的替代方案 + +- **对全部正文跑路径形状的正则**——会把随口提到的 `package.json` 和从未写过的示例都链接上;每个误报都是一次打开空无或错误文件的点击。词表方案不可能产生死链。 +- **链接后缀匹配(子目录列表里把 `out/index.html` 写作 `index.html`)**——暂缓;精确路径加唯一 basename 已覆盖观察到的收尾消息形态,之后放宽匹配器不会破坏这道 seam。 +- **在 ui-primitives 里对传入的路径列表做解析**——把匹配策略放进通用渲染器,其他消费方会被动继承。解析器 seam 让策略留在持有者手里。 +- **经由 turn-tail chain 传递词表**——chain 是消息下方的渲染派发;提及要装饰的是消息内部的 markdown,只有抵达 MarkdownText 的数据才做得到。可选 service 就是那条数据通路,它的缺席即关闭态。 + +## 后果 + +提及与产物行是同一事实的两个交互面,样式一致(静止即下划线,完整路径作为 `title`)。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts new file mode 100644 index 0000000000..badd9b845e --- /dev/null +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -0,0 +1,162 @@ +// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds +// a built write turn (zero model calls) whose closing message names the written +// file three ways: by unique basename (links), ambiguously (stays inert), and +// as a file the turn never touched (stays inert). Package tests cover the +// resolver in isolation; only the assembled application shows a real write's +// locations reaching the prose as an opener. The click itself is not driven +// here: it hands the path to the Host's opener, which would launch a real +// application on the machine running the suite (the produced-files restraint). +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const SEED_ID = 'produced-file-mentions-web-e2e' +const DONE = 'FILE_MENTION_DONE' + +/** One-part text content for a built message. */ +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +/** The files the built turn writes; `notes.md` is named in prose but never written. */ +const WRITES = ['site/report.html', 'a/style.css', 'b/style.css'] + +/** Build a settled write turn whose closing prose mentions files in inline code. */ +function mentionFixture(): string { + const session = Session.create(SessionId('produced-file-mentions-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Produced file mentions', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + const calls = WRITES.map((path, index) => ({ + path, + callId: CallId(`file-mention-${String(index)}`), + args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), + })) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: calls.map(call => ({ + type: 'tool-call' as const, + id: call.callId, + name: 'write', + arguments: call.args, + })), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + for (const call of calls) { + const source = session.append('tool/call', { + turn: 1, + step: 1, + callId: call.callId, + name: 'write', + arguments: call.args, + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: call.callId, + content: text(`Created ${call.path}`), + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + turn: 1, + step: 2, + message: createAssistantMessage({ + content: [{ + type: 'text', + text: [ + 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.', + '', + DONE, + ].join('\n'), + }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: inline-code mentions of produced files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, mentionFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Exactly one prose mention links: `report.html` resolves to the written + // path; the shared `style.css` basename and unwritten `notes.md` stay code. + const mentions = page.locator('[class*="markdown"] code button') + await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1) + expect(await mentions.first().innerText()).toBe('report.html') + expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html') + expect(await mentions.first().getAttribute('title')).toBe('site/report.html') + // The turn still ends with its produced-files row (all three writes). + expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1418cf2aa6..a147ddbcb8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,7 @@ "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", + "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index fdd1c41b80..2518ef28c8 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: d708621dbeb99615b0864e6977c8d2f387ae5265 -README.zh.md: e2cf9c3b32c86c2da39f09e4804c2ac69a3569f7 +README.md: 6182fa1281eec50f8898634cb466291b6ef1f5ff +README.zh.md: 8b110ec7713dcda5972d0ea0d9a8042301906d99 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d708621dbe..6182fa1281 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e2cf9c3b32..8b110ec771 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 24325c714e..48a0558f34 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -313,6 +313,7 @@ export function apply(ctx: Context): void { actions.select(target) layout.openDetails() }, + fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bc1c6c7e32..8bb7f8aea4 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -15,7 +15,8 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' @@ -43,6 +44,8 @@ export interface AssistantMarkdownProps { onFork?: ((seq: number) => void) | undefined /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined + /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ + fileMentions?: ChatViewInjected['fileMentions'] | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -86,11 +89,25 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, + fileMentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) + // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the + // growing transcript: a settled turn's produced files are final, and a + // fresh identity per append would discard MarkdownText's cached parse for + // every settled closing message on every stream chunk. The window-prepend + // edge (a mid-turn window start later gaining earlier same-turn writes) + // leaves a mention unlinked until remount — never a wrong link. + const owner = turnTail?.owner + const mentions: MarkdownFileMentions | undefined = useMemo( + () => (owner === undefined ? undefined : fileMentions?.(owner)), + // Deliberately not `owner`: its identity changes per append while the + // seq-addressed vocabulary it yields does not. + [fileMentions, owner?.seq], + ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -107,7 +124,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ {blocks.map((block, i) => { switch (block.kind) { case 'text': return ( - + ) case 'reasoning': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..c8ad485013 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -335,7 +335,8 @@ function StreamingTail({ useSession, t }: { * render through the declared keyed hole's renderSlot share). */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + fileMentions, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -603,6 +604,7 @@ export function ChatView({ turnTail={actionSeqs.has(node.seq) ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} + fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 89f7986dba..7e811da7a1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -4,6 +4,7 @@ import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -158,6 +159,30 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')` + * (optional-service convention): the chat view asks it for a closing message's + * inline-code vocabulary and threads the result into MarkdownText. Absent + * service — the providing plugin composed out of cordis.yml — turns the + * surface off; the prose renders inert code. + */ +export interface ChatFileMentions { + /** + * Mention vocabulary for the closing message the owner currency names. + * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @returns The resolver MarkdownText consumes, or undefined when the turn + * produced nothing worth linking. + */ + forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined +} + +declare module 'cordis' { + interface Context { + /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ + chatFileMentions: ChatFileMentions + } +} + /** * Owner currency of the chat view's turn-tail hole: the finalized snapshot * and the closing assistant's anchor. Registrants derive their own facts @@ -502,6 +527,13 @@ export interface ChatViewInjected { } /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void + /** + * Prose file-mention vocabulary for one closing message, from the optional + * {@link ChatFileMentions} service (resolved lazily per call, so composing + * the provider in or out takes effect live). Undefined when the service is + * absent or the turn produced nothing worth linking. + */ + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } /** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 725868d57a..3ce8b64053 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -13,6 +13,7 @@ export type { export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { ConversationKey } from './locales.ts' export type { + ChatFileMentions, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..cb11a1a5fd 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -154,6 +154,8 @@ function makeHarness(init?: Partial) { inspectCall, chatScroll, forkAt, + // Absent-service default; mention tests override with a real resolver. + fileMentions: () => undefined, // Mirrors the real lookup chain (conversation namespace, then common). t: makeTranslate(zh, commonZh), } @@ -248,6 +250,45 @@ describe('chat-flow derivation', () => { expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) }) + it('threads the injected file-mention vocabulary into the closing prose only', () => { + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const h = makeHarness({ + nodes: [ + user(1, 'build it'), + assistant(2, 'writing `report.html` now', 1), + wrote(3, 'w', 'site/report.html'), + assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1), + ], + turnEnds: new Map([[1, 4]]), + }) + // Stub provider mirroring the real service: only produced files resolve. + h.props.fileMentions = owner => ({ + resolve: (value) => { + if (value !== 'report.html') return undefined + return { + open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + label: '打开 site/report.html', + title: 'site/report.html', + } + }, + }) + const view = render() + // Exactly one live mention: the closing message links, the mid-turn + // narration stays inert code, and the unknown file resolves to nothing. + const mentions = view.container.querySelectorAll('code button') + expect(mentions).toHaveLength(1) + const mention = view.getByRole('button', { name: '打开 site/report.html' }) + expect(mention.getAttribute('title')).toBe('site/report.html') + fireEvent.click(mention) + // The vocabulary was built from the closing message's own owner currency. + expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') + }) + it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ee4c23c18c..3d25f13e1a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 -README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b +README.md: d6695f155907e7d92b35556588687b3f95e55b88 +README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index b8b0ea2ef1..d6695f1559 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention renders as the same underlined opener the row's chips are, with the full path as its `title`, and mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). + ## Model Experience None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. @@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests. ## Known Limitations and Deferred Work -- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. +- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index a16535b8a8..be360a5a1f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,6 +8,8 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener,完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 + ## 模型体验 无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 @@ -18,4 +20,4 @@ ## 已知限制与暂缓事项 -- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 +- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。 diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index ab85869de2..0baff2872a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -6,18 +6,13 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' /** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ const SHOWN = 6 -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - /** Matched paths plus the opener and locale seats needed to present them. */ export type ProducedFilesProps = Pick & { matched: readonly string[] diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 6dc7bc4b84..816cdff520 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,16 +1,18 @@ /** * Deliverables plugin, browser half: registers the produced-files row into - * the chat view's turn-tail hole. All policy lives here — the derivation - * from the mutation tools' `locations`, the chip cap, and the copy — so - * composing this plugin out of cordis.yml removes the surface entirely; the - * owning view renders an empty hole at zero cost. + * the chat view's turn-tail chain, and provides the `chatFileMentions` + * service that links inline-code mentions of produced files in the closing + * prose. All policy lives here — the derivation from the mutation tools' + * `locations`, the mention matching, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes both surfaces entirely; + * the owning view renders an empty chain and inert prose at zero cost. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,4 +41,15 @@ export function apply(ctx: ClientContext): void { locale: NS, }, ProducedFiles), ) + // The prose side of the same vocabulary: the chat view reaches this face + // via ctx.get, so its absence — this plugin composed out — is the off state. + const t = ctx.locale.bind(NS) + const mentions: ChatFileMentions = { + forClosing(owner) { + const paths = producedForClosing(owner.nodes, owner.seq) + if (paths.length === 0) return undefined + return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) + }, + } + ctx.provide('chatFileMentions', mentions) } diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..4316ddf8e6 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths } + +/** + * Trailing path segment, the part that identifies the file at a glance. + * @param path - Slash- or backslash-separated path. + * @returns The final segment, or the whole string when separator-free. + */ +export function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * File-mention vocabulary over one turn's produced paths, for the closing + * message's prose: an inline-code token opens the file it names. A token + * resolves by exact path, or by being exactly the basename of exactly one + * produced path — a basename two paths share stays inert rather than + * guessing, so a mention link can never open the wrong file or 404. + * @param paths - The turn's produced paths (tool order, already deduped). + * @param openFile - The chat view's file opener. + * @param label - Localizes the accessible open-label for a resolved path. + * @returns The resolver MarkdownText consumes; the full path rides `title`, + * the same disambiguator the row's chips carry. + */ +export function producedFileMentions( + paths: readonly string[], + openFile: (path: string) => void, + label: (path: string) => string, +): MarkdownFileMentions { + return { + resolve(value) { + const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value) + if (path === undefined) return undefined + return { open: () => { openFile(path) }, label: label(path), title: path } + }, + } +} + +/** The single produced path whose basename is exactly `value`, else undefined. */ +function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined { + const matches = paths.filter(path => basename(path) === value) + return matches.length === 1 ? matches[0] : undefined +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 49e41ebd86..65af40d642 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -13,9 +13,10 @@ import type { AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -141,6 +142,33 @@ describe('ProducedFiles row', () => { }) }) +describe('producedFileMentions resolver', () => { + const label = (path: string) => `打开 ${path}` + + it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => { + const opened: string[] = [] + const resolver = producedFileMentions( + ['out/index.html', 'a/style.css', 'b/style.css'], + (path) => { opened.push(path) }, + label, + ) + // Unique basename resolves to its full path; the full path rides title. + const byBasename = resolver.resolve('index.html') + expect(byBasename?.label).toBe('打开 out/index.html') + expect(byBasename?.title).toBe('out/index.html') + byBasename?.open() + expect(opened).toEqual(['out/index.html']) + // An exact path resolves even when its basename is ambiguous. + const exact = resolver.resolve('a/style.css') + expect(exact?.title).toBe('a/style.css') + // A basename two paths share stays unresolved rather than guessing, + // and so does a token naming nothing the turn wrote. + expect(resolver.resolve('style.css')).toBeUndefined() + expect(resolver.resolve('notes.md')).toBeUndefined() + expect(basename('a\\b\\c.txt')).toBe('c.txt') + }) +}) + describe('package shells', () => { it('the node half mounts inert and the invariant companion registers ownership', async () => { // The node half is deliberately inert; mounting it must simply not throw. @@ -172,7 +200,24 @@ describe('plugin registration', () => { await fiber.await() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + // The prose face is live while the plugin is: a produced turn yields a + // resolver whose matches open through the owner-supplied opener. + const opened: string[] = [] + const owner = { + nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], + seq: 3, + openFile: (path: string) => { opened.push(path) }, + } + const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') + const mentions = service?.forClosing(owner) + mentions?.resolve('report.html')?.open() + expect(opened).toEqual(['site/report.html']) + // A turn that produced nothing yields no vocabulary at all. + expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. + expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 47cebf796f..f7598d7a90 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4 -README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687 +README.md: a5f35c9a41008921c6a1e3a4de3fd3f38af926c0 +README.zh.md: 54c70ed29e73e196a9be60ddb794c12187dbe0c5 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7571cb4842..a5f35c9a41 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index fa0c3f2402..54c70ed29e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接:token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler),锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index fe0235c496..30a1dae402 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -40,7 +40,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' -export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 7e31857053..19375be18e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -241,3 +241,23 @@ background: var(--dsw-alias-bg-base); object-fit: contain; } + +/* Inline file mention: a real file named in prose is the same affordance as a + tool row's path link, so it reads the same — underlined at rest. */ +.fileMention { + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-decoration-color: var(--dsw-alias-label-quaternary); + text-underline-offset: 3px; + cursor: pointer; +} + +.fileMention:hover { + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index bb4b62514c..4dff78d784 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -19,20 +19,25 @@ import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' +import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -export type { MarkdownCodeLabels } from './render.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx' /** One settled full render: parse with math, resolve references, append the footnote section. */ -function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] { +function renderSettled( + text: string, + codeLabels: MarkdownCodeLabels | undefined, + fileMentions: MarkdownFileMentions | undefined, +): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() collectReferenceTargets(root.children, targets) const context: MarkdownRenderContext = { streaming: false, codeLabels, + fileMentions, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -96,6 +101,7 @@ class StreamingRenderer { const frozenContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, footnoteCounts: this.frozenFootnoteCounts, @@ -113,6 +119,7 @@ class StreamingRenderer { const tailContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], footnoteCounts: new Map(this.frozenFootnoteCounts), @@ -137,28 +144,33 @@ class StreamingRenderer { * the finalize swap) and parses incrementally across chunks; `codeLabels` * forwards localized copy-button labels to fence CodeBlocks — pass a * reference-stable object (memoized per locale revision), because a new - * identity discards the streaming render cache mid-message. + * identity discards the streaming render cache mid-message. `fileMentions` + * links inline-code tokens its resolver recognizes as real files; this is + * the single streaming gate — it applies to settled renders only, because a + * streaming message's vocabulary is not final and frozen cached elements + * must not bake in handlers that could go stale. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: { text: string streaming?: boolean codeLabels?: MarkdownCodeLabels | undefined + fileMentions?: MarkdownFileMentions | undefined }) { const streamRef = useRef(null) const streamLabelsRef = useRef(codeLabels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, codeLabels) + return renderSettled(text, codeLabels, fileMentions) } if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { streamRef.current = new StreamingRenderer(codeLabels) streamLabelsRef.current = codeLabels } return streamRef.current.render(text) - }, [text, streaming, codeLabels]) + }, [text, streaming, codeLabels, fileMentions]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 4ac8b4dbc1..452e0fc475 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -99,6 +99,21 @@ export function collectReferenceTargets( } } +/** + * File-mention affordance for inline code: the owner resolves an authored + * token to the file it names, using its own vocabulary of real files — the + * renderer never guesses at what looks like a path. + */ +export interface MarkdownFileMentions { + /** + * Resolve one inline-code token. + * @param value - The authored token, exactly as written. + * @returns The opener with its accessible label and full-path title, or + * undefined when the token names no known file — it then stays inert code. + */ + resolve(value: string): { open: () => void; label: string; title: string } | undefined +} + /** * One render pass's state: immutable options and targets plus the footnote * numbering accumulated in document order while references render. @@ -108,6 +123,10 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ + readonly fileMentions: MarkdownFileMentions | undefined + /** Inside an anchor's children: interactive mentions must not nest there. */ + readonly inLink?: boolean /** Reference targets visible to this pass. */ readonly targets: ReferenceTargets /** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */ @@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte // authored text, not a parsed destination, so no normalizeUri: port, // path, and query render unchanged. const href = inlineCodeHttpUrl(value) - return {href === undefined ? value : renderSafeLink(href, [value], 'link')} + if (href !== undefined) return {renderSafeLink(href, [value], 'link')} + // A token the owner's file-mention vocabulary recognizes opens that + // file; the resolver, not this renderer, decides what names a file. + // Inside an anchor the token stays inert — a button cannot nest there. + const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value) + if (mention !== undefined) { + return ( + + + + ) + } + return {value} } case 'html': // No HTML parser enters the pipeline: raw HTML stays literal text. @@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'table': return renderTable(node, key, context) case 'link': - return renderAnchor(node.url, renderChildren(node.children, context), key) + return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key) case 'linkReference': return renderLinkReference(node, key, context) case 'image': @@ -460,14 +499,14 @@ function renderLinkReference( context: MarkdownRenderContext, ): ReactNode { const definition = context.targets.definitions.get(node.identifier.toUpperCase()) - const children = renderChildren(node.children, context) if (definition === undefined) { // The grammar only emits references whose definitions exist somewhere in // the same parse, but incremental segments and hand-built trees may still - // present unresolved ones: revert to the bracketed source text. - return {'['}{children}{referenceSuffix(node)} + // present unresolved ones: revert to the bracketed source text — which is + // not an anchor, so mentions inside it stay live. + return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } - return renderAnchor(definition.url, children, key) + return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx index 48dd03f5c5..48f59c7479 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx @@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext { return { streaming: false, codeLabels: undefined, + fileMentions: undefined, targets: createReferenceTargets(), footnoteOrder: [], footnoteCounts: new Map(), diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 5066e32cd2..89ffd32346 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -148,6 +148,49 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code a')).toBeNull() }) + it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => { + const opened: string[] = [] + const fileMentions = { + resolve: (value: string) => value === 'index.html' || value === 'out/index.html' + ? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' } + : undefined, + } + const source = [ + '`index.html`', + '`other.css`', + '`https://example.com/`', + // Inside an anchor the mention stays inert code: a button cannot nest there. + '[see `out/index.html`](https://example.com/doc)', + '[ref `out/index.html`][target]', + '[target]: https://example.com/ref', + '```', + 'index.html', + '```', + ].join('\n\n') + const { container } = render() + + const mention = screen.getByRole('button', { name: 'Open out/index.html' }) + expect(mention.closest('code')).not.toBeNull() + // The full path rides title, the same disambiguator the row's chips carry. + expect(mention.getAttribute('title')).toBe('out/index.html') + fireEvent.click(mention) + expect(opened).toEqual(['index.html']) + // Exactly one live mention: the two inside anchors declined, and an + // unresolved token plus fenced code stay inert. + expect(container.querySelectorAll('code button')).toHaveLength(1) + expect(container.querySelectorAll('a code button, a button')).toHaveLength(0) + expect(screen.getByText('other.css').closest('button')).toBeNull() + // URL promotion wins before the resolver sees a token. + expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull() + + // Streaming renders keep mentions off — the one gate lives here: cached + // frozen elements must not bake in handlers that could go stale. + const streamed = render( + , + ) + expect(streamed.container.querySelector('button')).toBeNull() + }) + it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { const extension = cjkFriendlyStrong() expect(cjkFriendlyStrong()).toBe(extension) diff --git a/tsconfig.host.json b/tsconfig.host.json index ad2b5e32f1..d4f7eb8650 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", + "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 7ef39dc4b02795260c483c449b632303f95f6634 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:00:47 -0700 Subject: [PATCH 006/262] refactor(deliverables): reuse the chain claim test for the mention vocabulary --- packages/client/ui-deliverables/src/client/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 816cdff520..81b2f61b79 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -12,7 +12,7 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -46,8 +46,10 @@ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) const mentions: ChatFileMentions = { forClosing(owner) { - const paths = producedForClosing(owner.nodes, owner.seq) - if (paths.length === 0) return undefined + // Same claim test the turn-tail chain entry runs: no produced files, + // no vocabulary — the two surfaces agree by construction. + const paths = selectProducedFiles(owner) + if (paths === null) return undefined return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) }, } From 4c45355012bdc0d86d0fb8abe7e37c56b1e7c351 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:06:21 -0700 Subject: [PATCH 007/262] style(web): mention chips take the markdown anchor language Link-blue at rest with hover underline, matching URL-promoted inline code; an at-rest underline collides with monospace descenders inside the code chip. --- ...6-08-07-web-inline-file-mentions.i18n.yaml | 4 ++-- .../2026-08-07-web-inline-file-mentions.md | 2 +- .../2026-08-07-web-inline-file-mentions.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/markdown/MarkdownText.module.css | 20 ++++++++++--------- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml index e8fe387234..8153420c3d 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.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-07-web-inline-file-mentions.md -2026-08-07-web-inline-file-mentions.md: 79c6bddd500dc0b68bf64f3c4bea114ca41b4b62 -2026-08-07-web-inline-file-mentions.zh.md: ddc54d3b43570b19fb23c1c9c8079ef4ca65f8fa +2026-08-07-web-inline-file-mentions.md: 581efb5a9eb497e030d19118d52d003b37118108 +2026-08-07-web-inline-file-mentions.zh.md: 25c8ca6e106cd877a4d46a7b1338728e96771fc1 diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md index 79c6bddd50..581efb5a9e 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -25,4 +25,4 @@ The produced-files row lists a turn's output, but the closing message usually al ## Consequences -The mention and the row are two affordances for one fact, styled alike (underlined at rest, full path as `title`). `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. +The mention and the row are two affordances for one fact (full path as `title` on both); the mention itself wears the markdown sheet's anchor language — link-blue at rest, hover underline — because an at-rest underline collides with monospace descenders inside the code chip. `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md index ddc54d3b43..25c8ca6e10 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -25,4 +25,4 @@ Status: implemented ## 后果 -提及与产物行是同一事实的两个交互面,样式一致(静止即下划线,完整路径作为 `title`)。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 +提及与产物行是同一事实的两个交互面(两者都以完整路径作 `title`);提及本身采用 markdown 样式表的锚点语言——静止为链接蓝、悬停出下划线——因为静止下划线在 code 胶囊里会压住等宽字的下伸部。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 3d25f13e1a..7c2cb356ec 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: d6695f155907e7d92b35556588687b3f95e55b88 -README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b +README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2 +README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index d6695f1559..189dedd88f 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,7 +8,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). -The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention renders as the same underlined opener the row's chips are, with the full path as its `title`, and mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). ## Model Experience diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index be360a5a1f..bfcec3c546 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,7 +8,7 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 -收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener,完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 19375be18e..b62e66e86e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -242,22 +242,24 @@ object-fit: contain; } -/* Inline file mention: a real file named in prose is the same affordance as a - tool row's path link, so it reads the same — underlined at rest. */ +/* Inline file mention: the same link language this sheet gives anchors (and + thereby URL-promoted inline code) — link-blue at rest, underline only on + hover/focus. An underline at rest reads badly inside the code chip, where + it collides with monospace descenders and the pill background. */ .fileMention { margin: 0; padding: 0; border: none; background: none; font: inherit; - color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-decoration-color: var(--dsw-alias-label-quaternary); - text-underline-offset: 3px; + color: var(--dsw-alias-state-business-primary); + text-decoration: none; cursor: pointer; } -.fileMention:hover { - color: var(--dsw-alias-label-primary); - text-decoration-color: currentColor; +.fileMention:hover, +.fileMention:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); + text-underline-offset: 3px; } From 9ed33dfe4f8e784536e0741ed447af638e8e26d4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:08:22 +0800 Subject: [PATCH 008/262] refactor(client): localize nested Tool call trees --- ...ient-tool-presentation-ownership.i18n.yaml | 4 +- ...8-08-client-tool-presentation-ownership.md | 26 +-- ...8-client-tool-presentation-ownership.zh.md | 26 +-- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- packages/client/runtime/src/client/index.ts | 13 +- .../client/session-history/history-fold.ts | 92 +++------- .../src/client/sessions/conversation.ts | 28 +-- .../runtime/src/client/sessions/history.ts | 6 +- .../runtime/src/client/sessions/session.ts | 87 ++------- .../src/client/sessions/tool-call-tree.ts | 171 ++++++++++++++++++ .../src/client/sessions/transcript-adapter.ts | 1 + packages/client/runtime/tests/session.spec.ts | 81 +++++++-- packages/client/test-runtime/src/fixtures.ts | 1 - .../src/client/chat/ChatView.tsx | 11 +- .../src/client/skeleton/DetailsPanel.tsx | 28 ++- .../ui-conversation/tests/chat-stats.spec.tsx | 6 +- .../ui-conversation/tests/chat-view.spec.tsx | 6 +- .../tests/gate-branch-tails.spec.tsx | 26 ++- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/produced-files.spec.tsx | 2 +- .../client/ui-skill/tests/skill-row.spec.tsx | 3 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 5 +- packages/client/ui-tool/README.zh.md | 5 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 65 ++++--- .../ui-tool/tests/ask-question-row.spec.tsx | 4 +- .../ui-tool/tests/assembly-surfaces.spec.tsx | 3 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 59 +++--- .../ui-tool/tests/coverage-tails.spec.tsx | 8 +- .../client/ui-tool/tests/diff-card.spec.tsx | 6 +- .../client/ui-tool/tests/read-card.spec.tsx | 6 +- .../client/ui-tool/tests/search-card.spec.tsx | 8 +- .../ui-tool/tests/terminal-card.spec.tsx | 17 +- .../client/ui-tool/tests/todo-row.spec.tsx | 4 +- .../ui-tool/tests/tool-call-tree.spec.tsx | 35 ++-- .../client/ui-tool/tests/tool-row.spec.tsx | 4 +- .../ui-tool/tests/toolview-slot.spec.tsx | 2 +- .../client/ui-tool/tests/web-card.spec.tsx | 8 +- .../src/client/TrajectoryView.tsx | 4 +- .../client/ui-trajectory/src/client/layout.ts | 48 +++-- .../ui-trajectory/tests/layout.spec.tsx | 59 ++++-- .../client/ui-trajectory/tests/views.spec.tsx | 5 +- 48 files changed, 586 insertions(+), 413 deletions(-) create mode 100644 packages/client/runtime/src/client/sessions/tool-call-tree.ts diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 5097b9e8a9..e1beb13c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.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/architecture/2026-08-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: e61c2030457cc9f0fda214e896b76afb37d0d2bc -2026-08-08-client-tool-presentation-ownership.zh.md: 5c56b8c17ef5ca6695f3b28f6b93218dade356c7 +2026-08-08-client-tool-presentation-ownership.md: 9ea7d7dfc5e18e541550569795c5e1db84d6ad91 +2026-08-08-client-tool-presentation-ownership.zh.md: 11a42ad69e4374afa6dccd3af74b8bd1b5e5c6f1 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index e61c203045..9ea7d7dfc5 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -12,19 +12,19 @@ That ownership made `ui-conversation` interpret business Tool names and made sub ## Decision -Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Session Event, projection, fold, `ConversationSnapshot` construction and caching, historical paging, and Code Dispatch indexing remain unchanged. +Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map. “First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. `ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. -`ui-tool` occupies that whole-Tool seat. Through its standard session slot props, `ToolCallTree` selects the Runtime-projected `codeDispatches[rootCallId]` array, renders the root followed by that one currently supported child level, and routes both forms through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. This is deliberately one-level composition, not a claim that the Runtime supports an arbitrary recursive call graph. +`ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology. Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this seam. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. -The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch stays a top-level official concept because it changes `codeDispatches` and parent/child identity; ordinary Tool business differences stay at the keyed presentation seam. This package boundary does not add a Tool projector/fold registry. +The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation seam, and this package boundary adds no Tool projector/fold registry. ## Runtime and render path @@ -39,22 +39,22 @@ ConversationSnapshot.runningCalls | -> ChatView flow tail ---------------+-> ToolSeat -> conversation.chat.tool -> ToolCallTree -ConversationSnapshot.codeDispatches[rootCallId] -+ - +-> root ToolCall + one-level child ToolCall - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback + -> root ToolCallBlock + `- subCalls[] (recursive) + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -The live Session's [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) caches arrays or maps such as `nodes`, `runningCalls`, and `codeDispatches` against independent revisions. Their references stay stable when the corresponding business state has not changed, allowing React selectors and memoization to skip unrelated updates. The historical projection's [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) reconstructs the same running-call and Code Dispatch shapes from entries in its window. Tool UI consumes the snapshot shapes already unified by those paths; presentation packages do not repeat call/result pairing, historical replay, or cache indexing. +Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. `ToolCallTree` selects only the current root's `codeDispatches[rootCallId]`; it does not introduce a business projector for presentation of other roots. +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree. ## Code and responsibility boundaries | Owner | Primary code | Owns | Explicitly does not own | |---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, Code Dispatch parent/child index, snapshot reference stability | Business views selected by Tool name | +| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name | | `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | | `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | | Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | @@ -82,7 +82,7 @@ ctx.slots.inject('tool.call.toolview', () => ## Details path -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) still locates the selected call in `nodes`, `runningCalls`, and `codeDispatches`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. +[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. ## Verification @@ -96,7 +96,7 @@ Test ownership follows production ownership. `ui-conversation` tests install a l **Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. -**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Root/child composition belongs to `ui-tool`, and the current wire/runtime shape only supports one Code Dispatch child level. +**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`. **Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index 5c56b8c17e..11a42ad69e 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -12,19 +12,19 @@ Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `cal ## Decision -Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Session Event、projection、fold、`ConversationSnapshot` 构建与缓存、历史分页及 Code Dispatch 索引保持不变。 +Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map。 这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 `ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 -`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,先渲染 root,再渲染当前支持的一层 child;两种调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。这里刻意只编排一层,并不声称 Runtime 已支持任意递归调用图。 +`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑。 业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 seam 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 -Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 会改变 `codeDispatches` 与 parent/child identity,因此继续作为官方顶级概念;普通 Tool 业务差异停留在 keyed 展示 seam。这个包边界不会增加 Tool projector/fold registry。 +Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示 seam,这个包边界不会增加 Tool projector/fold registry。 ## Runtime 与渲染链路 @@ -39,22 +39,22 @@ ConversationSnapshot.runningCalls | -> ChatView flow tail ---------------+-> ToolSeat -> conversation.chat.tool -> ToolCallTree -ConversationSnapshot.codeDispatches[rootCallId] -+ - +-> root ToolCall + one-level child ToolCall - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback + -> root ToolCallBlock + `- subCalls[] (recursive) + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Live Session 的 [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 按独立 revision 缓存 `nodes`、`runningCalls`、`codeDispatches` 等数组或 map;没有对应业务变化时,它们保持引用稳定,供 React selector 与 memo 跳过无关更新。历史 projection 的 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 从窗口内 entry 重建相同的 running call 与 Code Dispatch 形态。Tool UI 直接消费这两个路径已经统一的 snapshot,不在展示包中重复 call/result 配对、历史 replay 或缓存索引。 +Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。`ToolCallTree` 只选择当前 root 的 `codeDispatches[rootCallId]`,不会因其他 root 的展示逻辑引入业务 projector。 +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root,`ToolCallTree` 再沿该 root 的局部树递归渲染。 ## 代码与职责边界 | 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | |---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、Code Dispatch parent/child 索引、snapshot 引用稳定性 | Tool 名称对应的业务视图 | +| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 | | `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | | `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | | 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | @@ -82,7 +82,7 @@ ctx.slots.inject('tool.call.toolview', () => ## Details 路径 -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 仍从 `nodes`、`runningCalls` 与 `codeDispatches` 中定位选中的 call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 +[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes` 与 `runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 ## Verification @@ -96,7 +96,7 @@ ctx.slots.inject('tool.call.toolview', () => **增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 -**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。root/child 编排归 `ui-tool`,且当前 wire/runtime 形态只支持一层 Code Dispatch child。 +**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` 的 `ToolCallTree`。 **让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f45ceb712d..bb64a12d4e 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: bb85d3c7b45eb0d82d789a9133bba787f1d6c5e9 -README.zh.md: 18aedbb487a490c85ce68ccc460fd82218157843 +README.md: fad0f6e4f948ebf412b2bd837a4f68e6dc805ec8 +README.zh.md: 4ede12a320082a5023ec121306650fce72a12663 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index bb85d3c7b4..fad0f6e4f9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -44,9 +44,9 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr `SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. -## Code Mode sub-dispatch index +## Code Mode child-call tree -`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps. +Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. ## Session title projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 18aedbb487..4ede12a320 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -44,9 +44,9 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 -## Code Mode 子调用索引 +## Code Mode 子调用树 -`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 +每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。 ## Session 标题投影 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 3c4e8e0bf4..b7226c0085 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -8,7 +8,7 @@ import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' -import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' +import type { ConversationSnapshot } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' export { SlotsService } from './slots.ts' @@ -49,10 +49,10 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, + AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall, - SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { ConversationContext, ConversationContextOriginKind, @@ -89,13 +89,6 @@ declare module '@deepseek-ai/dsh-type-meta' { /** The conversation-snapshot selector hook supplied to session-scoped UI entries. */ export type UseConversationSession = SnapshotSelectorHook -/** - * One tool call as the chat flow renders it: still-running (spinner card) or - * settled (result node). The fold produces both shapes; toolview components - * narrow on the discriminant fields. - */ -export type ToolCallBlock = RunningToolCall | ToolResultNode - declare module '@deepseek-ai/dsh-client-ui-slots' { /** * Session standard kit, real members (ui-slots declares the empty seat; diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index d792fd2b76..42aaa37027 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -1,4 +1,3 @@ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, @@ -7,7 +6,7 @@ import type { HistoryEntry, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { - AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode, + AssistantRequestConfig, AssistantTiming, ConversationNode, PartialAssistant, RunningToolCall, } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts' @@ -20,6 +19,7 @@ import type { ConversationPromptSnapshot } from '../sessions/request-inspection. import { PartialAccumulator } from '../sessions/partial.ts' import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts' import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts' +import { ToolCallTree } from '../sessions/tool-call-tree.ts' interface CallIndexEntry { name: string @@ -41,7 +41,6 @@ export interface ConversationHistoryProjection { interruptedNodes: readonly ConversationNode[] partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] - codeDispatches: ReadonlyMap } function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { @@ -177,6 +176,7 @@ function materializeNode( meta: event.data.meta, callView: call?.callView ?? null, resultView, + subCalls: [], } } default: @@ -188,74 +188,22 @@ function materializeNode( } /* jscpd:ignore-end */ -function projectTransient(entries: readonly HistoryEntry[]): Pick< +interface TransientProjection extends Pick< ConversationHistoryProjection, - 'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches' + 'interruptedNodes' | 'partial' | 'runningCalls' > { + toolCallTree: ToolCallTree +} + +function projectTransient(entries: readonly HistoryEntry[]): TransientProjection { let partial: PartialAccumulator | null = null const openCalls = new Map() const interruptedNodes: ConversationNode[] = [] - const codeDispatches = new Map() + const toolCallTree = new ToolCallTree() for (const entry of entries) { const { event } = entry - if ((event.type as string) === 'tool/code-dispatch-start') { - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - } - const siblings = codeDispatches.get(data.parentCallId) ?? [] - // The independent replay emits the same public running-call shape as - // Chat without reading or mutating Session's live index. - /* jscpd:ignore-start */ - codeDispatches.set(data.parentCallId, [...siblings, { - callId: data.subCallId, - name: data.name, - argsRaw: JSON.stringify(data.arguments), - turn: 0, - step: 0, - time: event.time, - callView: null, - }]) - /* jscpd:ignore-end */ - continue - } - if ((event.type as string) === 'tool/code-dispatch') { - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - isError: boolean - content: ContentBlock[] - } - const siblings = codeDispatches.get(data.parentCallId) ?? [] - const at = siblings.findIndex(sub => sub.callId === data.subCallId) - const started = at === -1 ? undefined : siblings[at] - // History independently reproduces the public settled-call shape instead - // of consuming Session's live code-dispatch projection. - /* jscpd:ignore-start */ - const settled: CodeSubCall = { - kind: 'tool-result', seq: event.seq, time: event.time, - callId: data.subCallId, - call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, - callTime: started?.time ?? null, - content: data.content, - isError: data.isError, - callView: null, - resultView: null, - } - codeDispatches.set( - data.parentCallId, - at === -1 - ? [...siblings, settled] - : siblings.map((sub, index) => index === at ? settled : sub), - ) - /* jscpd:ignore-end */ - continue - } + if (toolCallTree.apply(event)) continue switch (event.type) { case 'assistant/chunk': { const { turn, step, chunk } = event.data @@ -280,6 +228,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick< step: event.data.step, time: event.time, callView: entry.view?.for === 'call' ? entry.view.view : null, + subCalls: [], }) /* jscpd:ignore-end */ break @@ -317,6 +266,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick< error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, + subCalls: [], }) /* jscpd:ignore-end */ } @@ -331,7 +281,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick< interruptedNodes, partial: partial?.toPartial() ?? null, runningCalls: [...openCalls.values()], - codeDispatches, + toolCallTree, } } @@ -462,9 +412,17 @@ export function projectConversationHistory( } } + const transient = projectTransient(entries) + const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes) + const projectedContexts = contexts.map((context): ConversationContext => { + const nodes = transient.toolCallTree.projectNodes(context.nodes) + return nodes === context.nodes ? context : { ...context, nodes } + }) return { - eventNodes, - contexts, - ...projectTransient(entries), + eventNodes: projectedEventNodes, + contexts: projectedContexts, + interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes), + partial: transient.partial, + runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls), } } diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a881158525..dba9ec9513 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -174,6 +174,8 @@ export interface ToolResultNode { callView: ToolCallView | null /** Host-computed render intent from this tool/result's wire view; null = same default. */ resultView: ToolResultView | null + /** Child calls owned by this call, in dispatch order. */ + subCalls: readonly ToolCallBlock[] } /** @@ -263,21 +265,6 @@ export type ConversationNode = | CompactionSummaryNode | UnknownSurfaceNode -/** - * One `run_code` sub-dispatch materialized in the native call-block shapes so - * every consumer (tool rows, details panel) renders it through the exact - * components that render a native call: a started-but-unsettled sub-call is a - * {@link RunningToolCall} (rows derive the running state from the shape, - * exactly as for native calls) and its `tool/code-dispatch` settlement - * replaces it in place with the {@link ToolResultNode} form. Never part of - * the transcript `nodes` flow — sub-calls live under their parent via - * {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic - * sub-call id (`:code:`); the call side carries the sub-tool name - * and its JSON-stringified logged arguments; `content`/`isError` are the - * settled sub-call's complete logged outcome. - */ -export type CodeSubCall = RunningToolCall | ToolResultNode - /** In-flight tool card material: tool/call seen, tool/result not yet. */ export interface RunningToolCall { callId: string @@ -289,8 +276,12 @@ export interface RunningToolCall { time: number /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ callView: ToolCallView | null + /** Child calls owned by this call, in dispatch order. */ + subCalls: readonly ToolCallBlock[] } +/** One running or settled call, recursively owning its child calls. */ +export type ToolCallBlock = RunningToolCall | ToolResultNode /** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ export interface QueuedMessage { @@ -355,13 +346,6 @@ export interface ConversationSnapshot { turnEnds: ReadonlyMap partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] - /** - * `run_code` sub-dispatches grouped under their parent callId, in dispatch - * order. Populated from in-window `tool/code-dispatch` events (live and - * replay identically); the per-parent array reference is stable across - * unrelated snapshot swaps (memo premise, same regime as `nodes`). - */ - codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] /** Authoritative transient inbox snapshot, including queued and steering placements. */ queue: readonly QueuedMessage[] diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts index fbf7b08f3d..8609481d33 100644 --- a/packages/client/runtime/src/client/sessions/history.ts +++ b/packages/client/runtime/src/client/sessions/history.ts @@ -1,7 +1,7 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' import type { - CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall, + ConversationNode, PartialAssistant, RunningToolCall, } from './conversation.ts' import type { ConversationContext } from './conversation-context.ts' import { projectConversationHistory } from '../session-history/history-fold.ts' @@ -34,7 +34,6 @@ export interface SessionHistoryInspection { interruptedNodes: readonly ConversationNode[] partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] - codeDispatches: ReadonlyMap } /** @@ -112,9 +111,6 @@ export function createHistoryInspection( get runningCalls() { return conversationProjection().runningCalls }, - get codeDispatches() { - return conversationProjection().codeDispatches - }, get requests() { return requestProjection().requests }, diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 776f4494fd..075fd686ea 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -13,7 +13,7 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, + ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' @@ -24,6 +24,7 @@ import { Notifier } from './notifier.ts' import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' +import { ToolCallTree } from './tool-call-tree.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 @@ -129,11 +130,8 @@ export class Session implements SessionFace { private queued: QueuedMessage[] = [] private queueRev = 0 private queueCache: { rev: number; value: QueuedMessage[] } | null = null - /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends - * copy-on-write the per-parent array so published snapshot references never mutate. */ - private codeDispatches = new Map() - private dispatchesRev = 0 - private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null + /** Window-derived child-call lifecycle and immutable tree projection. */ + private readonly toolCallTree = new ToolCallTree() private running = false private address: SubagentAddress | undefined private parentAvailable = false @@ -746,65 +744,10 @@ export class Session implements SessionFace { this.derivedRev++ return } - // The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by - // the host-side dsh-tools plugin whose types cannot enter the client - // program (its host Context merges collide with the client's), so this - // wire consumer narrows them structurally — the same posture as every - // other cross-wire event payload. - if ((event.type as string) === 'tool/code-dispatch-start') { - // A started sub-dispatch enters the index as a RunningToolCall — the - // exact shape a native in-flight call renders from — under its parent - // run_code callId; it never joins the surface flow. - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - } - const running: CodeSubCall = { - callId: data.subCallId, name: data.name, - argsRaw: JSON.stringify(data.arguments), - turn: 0, step: 0, time: event.time, callView: null, - } - const siblings = this.codeDispatches.get(data.parentCallId) ?? [] - this.codeDispatches.set(data.parentCallId, [...siblings, running]) - this.dispatchesRev++ - return - } - if ((event.type as string) === 'tool/code-dispatch') { - // Settlement replaces the running entry in place (same array position, - // so parallel sub-calls keep their start order) with the - // ToolResultNode form; a settle with no observed start (history window - // cut mid-pair, or a pre-start-event log) appends directly. - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - isError: boolean - content: ContentBlock[] - } - const siblings = this.codeDispatches.get(data.parentCallId) ?? [] - const at = siblings.findIndex(sub => sub.callId === data.subCallId) - const started = at === -1 ? undefined : siblings[at] - const settled: CodeSubCall = { - kind: 'tool-result', seq: event.seq, time: event.time, - callId: data.subCallId, - call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, - // Duration source: the paired start's time when observed; null = - // unknown (settle-only window), matching the native tool-result - // contract so views never present a fabricated zero duration. - callTime: started === undefined ? null : started.time, - content: data.content, isError: data.isError, - callView: null, resultView: null, - } - this.codeDispatches.set( - data.parentCallId, - at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)), - ) - this.dispatchesRev++ - return - } + // These lifecycle events are declared by a host-only plugin whose Context + // types cannot enter the client program. ToolCallTree owns their structural + // wire narrowing, pairing, and nested snapshot projection. + if (this.toolCallTree.apply(event)) return switch (event.type) { case 'turn/start': this.lastStepByTurn.set(event.data.turn, 0) @@ -834,6 +777,7 @@ export class Session implements SessionFace { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, time: event.time, callView: view?.for === 'call' ? view.view : null, + subCalls: [], }) this.callsRev++ return @@ -901,7 +845,7 @@ export class Session implements SessionFace { call: { name: call.name, argsRaw: call.argsRaw }, callTime: call.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, - callView: call.callView, resultView: null, + callView: call.callView, resultView: null, subCalls: [], }) this.derivedRev++ } @@ -948,8 +892,7 @@ export class Session implements SessionFace { this.turnTimingsRev++ this.turnEnds = new Map() this.turnEndsRev++ - this.codeDispatches = new Map() - this.dispatchesRev++ + this.toolCallTree.reset() for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -988,22 +931,18 @@ export class Session implements SessionFace { if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } - if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) { - this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } - } if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued } } const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, - nodes, + nodes: this.toolCallTree.projectNodes(nodes), turnTimings: this.turnTimingsCache.value, turnEnds: this.turnEndsCache.value, partial, - runningCalls: this.callsCache.value, + runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value), pending: this.pendingCache.value, - codeDispatches: this.dispatchesCache.value, queue: this.queueCache.value, running: this.running, subagent: this.address === undefined diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/runtime/src/client/sessions/tool-call-tree.ts new file mode 100644 index 0000000000..ef25ba3da2 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/tool-call-tree.ts @@ -0,0 +1,171 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, +} from './conversation.ts' + +interface ProjectedBlock { + source: ToolCallBlock + children: readonly ToolCallBlock[] + value: ToolCallBlock +} + +function sameBlocks( + left: readonly ToolCallBlock[], + right: readonly ToolCallBlock[], +): boolean { + return left.length === right.length + && left.every((block, index) => block === right[index]) +} + +/** + * Owns Code Dispatch pairing and projects its private parent index into the + * recursive Tool call contract exposed by conversation snapshots. + */ +export class ToolCallTree { + private readonly childrenByParent = new Map() + private readonly projectedByCall = new Map() + private revision = 0 + private nodesCache: { + source: readonly ConversationNode[] + revision: number + value: readonly ConversationNode[] + } | null = null + private runningCache: { + source: readonly RunningToolCall[] + revision: number + value: readonly RunningToolCall[] + } | null = null + + /** Forget all event-derived child calls before replaying a new window. */ + reset(): void { + this.childrenByParent.clear() + this.projectedByCall.clear() + this.revision++ + } + + /** + * Fold one event when it belongs to the Code Dispatch lifecycle. + * @param event - Session event from the current live or history window. + * @returns Whether the event was consumed as a child-call lifecycle event. + */ + apply(event: SessionEvent): boolean { + if ((event.type as string) === 'tool/code-dispatch-start') { + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + } + const running: RunningToolCall = { + callId: data.subCallId, + name: data.name, + argsRaw: JSON.stringify(data.arguments), + turn: 0, + step: 0, + time: event.time, + callView: null, + subCalls: [], + } + const siblings = this.childrenByParent.get(data.parentCallId) ?? [] + this.childrenByParent.set(data.parentCallId, [...siblings, running]) + this.revision++ + return true + } + if ((event.type as string) !== 'tool/code-dispatch') return false + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + isError: boolean + content: ContentBlock[] + } + const siblings = this.childrenByParent.get(data.parentCallId) ?? [] + const at = siblings.findIndex(sub => sub.callId === data.subCallId) + const started = at === -1 ? undefined : siblings[at] + const settled: ToolResultNode = { + kind: 'tool-result', + seq: event.seq, + time: event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, + callTime: started?.time ?? null, + content: data.content, + isError: data.isError, + callView: null, + resultView: null, + subCalls: [], + } + this.childrenByParent.set( + data.parentCallId, + at === -1 + ? [...siblings, settled] + : siblings.map((sub, index) => index === at ? settled : sub), + ) + this.revision++ + return true + } + + /** + * Attach recursively projected children to all settled roots in a node list. + * @param nodes - Cache-stable base conversation nodes. + * @returns The original list when no root changed, otherwise a structurally shared list. + */ + projectNodes(nodes: readonly ConversationNode[]): readonly ConversationNode[] { + if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) { + return this.nodesCache.value + } + let changed = false + const projected = nodes.map((node): ConversationNode => { + if (node.kind !== 'tool-result') return node + const value = this.projectBlock(node) as ToolResultNode + changed ||= value !== node + return value + }) + const value = changed ? projected : nodes + this.nodesCache = { source: nodes, revision: this.revision, value } + return value + } + + /** + * Attach recursively projected children to all running root calls. + * @param calls - Cache-stable base running calls. + * @returns The original list when no root changed, otherwise a structurally shared list. + */ + projectRunningCalls(calls: readonly RunningToolCall[]): readonly RunningToolCall[] { + if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) { + return this.runningCache.value + } + let changed = false + const projected = calls.map((call): RunningToolCall => { + const value = this.projectBlock(call) as RunningToolCall + changed ||= value !== call + return value + }) + const value = changed ? projected : calls + this.runningCache = { source: calls, revision: this.revision, value } + return value + } + + private projectBlock(block: ToolCallBlock): ToolCallBlock { + const children = this.childrenByParent.get(block.callId) ?? block.subCalls + const projectedChildren = children.map(child => this.projectBlock(child)) + const childValue = sameBlocks(children, projectedChildren) + ? children + : projectedChildren + const cached = this.projectedByCall.get(block.callId) + if (cached?.source === block && sameBlocks(cached.children, childValue)) { + return cached.value + } + const value: ToolCallBlock = block.subCalls === childValue + ? block + : { ...block, subCalls: childValue } + this.projectedByCall.set(block.callId, { + source: block, + children: childValue, + value, + }) + return value + } +} diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index ae7643f0de..25fade8633 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -103,6 +103,7 @@ function materializeNode( meta: event.data.meta, callView: call?.callView ?? null, resultView, + subCalls: [], } } /* v8 ignore next 2 -- defensive arm: only the four surface-eligible types diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c288c044ee..b5400ecff9 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -1151,7 +1151,17 @@ describe('resync', () => { }) -describe('run_code sub-dispatch indexing', () => { +describe('nested run_code sub-dispatches', () => { + const subCallsOf = (session: Session, callId: string) => { + const snapshot = session.getSnapshot() + const running = snapshot.runningCalls.find(call => call.callId === callId) + if (running !== undefined) return running.subCalls + for (const node of snapshot.nodes) { + if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls + } + return undefined + } + it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) @@ -1161,19 +1171,19 @@ describe('run_code sub-dispatch indexing', () => { feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' })) feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' })) - const live = session.getSnapshot().codeDispatches.get('p1') + const live = subCallsOf(session, 'p1') expect(live).toHaveLength(2) // Running shape (no 'kind'): the exact RunningToolCall form native rows use. expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' }) expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false) // Settle out of order (parallel run): #2 first — replaces in place, keeping start order. feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha')) - const mixed = session.getSnapshot().codeDispatches.get('p1') + const mixed = subCallsOf(session, 'p1') expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true]) expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] }) // The settle carries the paired start's time as callTime (duration source). feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done')) - const settled = session.getSnapshot().codeDispatches.get('p1') + const settled = subCallsOf(session, 'p1') expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true]) expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 }) }) @@ -1187,7 +1197,7 @@ describe('run_code sub-dispatch indexing', () => { feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}')) feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt')) feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true)) - const subs = session.getSnapshot().codeDispatches.get('p1') + const subs = subCallsOf(session, 'p1') expect(subs).toHaveLength(2) expect(subs?.[0]).toMatchObject({ kind: 'tool-result', callId: 'p1:code:1', @@ -1205,23 +1215,29 @@ describe('run_code sub-dispatch indexing', () => { expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false) }) - it('rebuilds the same index from a history window (replay parity)', async () => { + it('rebuilds the same nested tree from a history window (replay parity)', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse([ ...plainTurn(0, 0, '问', '答'), ev.turnStart(6, 1), ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'), - ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'), - ev.toolResult(9, 1, 'p1', '{"done":true}'), - ev.turnEnd(10, 1), + ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }), + ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'), + ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'), + ev.toolResult(11, 1, 'p1', '{"done":true}'), + ev.turnEnd(12, 1), ]) await session.open() - const subs = session.getSnapshot().codeDispatches.get('p1') + const subs = subCallsOf(session, 'p1') expect(subs).toHaveLength(1) - expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } }) + expect(subs?.[0]).toMatchObject({ + callId: 'p1:code:1', + call: { name: 'run_code' }, + subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }], + }) }) - it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => { + it('keeps an unaffected root reference and path-copies it on a new child', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) await session.open() @@ -1230,13 +1246,48 @@ describe('run_code sub-dispatch indexing', () => { feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x')) const before = session.getSnapshot() + const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')! feed(ev.chunkStart(9, 1)) feed(ev.chunkText(10, 1, '流式')) const after = session.getSnapshot() - expect(after.codeDispatches).toBe(before.codeDispatches) + const afterRoot = after.runningCalls.find(call => call.callId === 'p1')! + expect(afterRoot).toBe(beforeRoot) feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y')) - expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches) - expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2) + const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')! + expect(changedRoot).not.toBe(afterRoot) + expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0]) + expect(changedRoot.subCalls).toHaveLength(2) + }) + + it('path-copies only the owning branch when a nested child changes', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构')) + await session.open() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.turnStart(6, 1)) + feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}')) + feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}')) + feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child')) + feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling')) + feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two')) + const before = session.getSnapshot() + const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')! + const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')! + const beforeChild = beforeFirst.subCalls[0]! + const beforeSibling = beforeFirst.subCalls[1]! + + feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf')) + const after = session.getSnapshot() + const afterFirst = after.runningCalls.find(call => call.callId === 'p1')! + const afterSecond = after.runningCalls.find(call => call.callId === 'p2')! + + expect(afterFirst).not.toBe(beforeFirst) + expect(afterSecond).toBe(beforeSecond) + expect(afterFirst.subCalls[0]).not.toBe(beforeChild) + expect(afterFirst.subCalls[1]).toBe(beforeSibling) + expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([ + { callId: 'p1:code:1:code:1', call: { name: 'read' } }, + ]) }) }) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 9cf025192c..ea01893346 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -50,7 +50,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot turnEnds: new Map(), partial: null, runningCalls: [], - codeDispatches: new Map(), pending: [], queue: [], running: false, diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index c52a4623be..d021e2f388 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,7 +24,7 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' import type { - CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, + CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -111,6 +111,11 @@ type ChatScrollPosition = NonNullable +function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean { + return callId !== undefined + && (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId))) +} + function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { if (!running) return null for (let index = nodes.length - 1; index >= 0; index -= 1) { @@ -174,7 +179,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec toolName={node.call?.name ?? ''} block={node} openFile={openFile} - selectedCallId={selectedCallId} + selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined} cwd={cwd} inspectCall={inspectCall} /> @@ -595,7 +600,7 @@ export function ChatView({ toolName={call.name} block={call} openFile={openFile} - selectedCallId={selectedCallId} + selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined} cwd={cwd} inspectCall={inspectCall} /> diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index c2104c23c7..a04e9ba9e8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -40,19 +40,27 @@ function runningMaterial(call: RunningToolCall): CallMaterial { return { name: call.name, argsRaw: call.argsRaw, block: call } } +function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined { + if (block.callId === callId) return block + for (const child of block.subCalls) { + const found = findCall(child, callId) + if (found !== undefined) return found + } + return undefined +} + function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { for (const node of s.nodes) { - if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId) + if (node.kind !== 'tool-result') continue + const found = findCall(node, callId) + if (found !== undefined) { + return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) + } } - const open = s.runningCalls.find(c => c.callId === callId) - if (open !== undefined) return runningMaterial(open) - // run_code sub-dispatches: the native call-block shapes, so a selected - // sub-row resolves through the same material as a native call — the - // settled ToolResultNode form, or the RunningToolCall form mid-flight. - for (const subs of s.codeDispatches.values()) { - for (const sub of subs) { - if (sub.callId !== callId) continue - return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub) + for (const root of s.runningCalls) { + const found = findCall(root, callId) + if (found !== undefined) { + return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) } } return null diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx index cdb0ffa8c9..78fbf43c5e 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -42,7 +42,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -86,7 +86,7 @@ describe('deriveStats', () => { it('ignores tool results with no call time', () => { const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], - isError: false, callView: null, resultView: null, + isError: false, callView: null, resultView: null, subCalls: [], } const stats = deriveStats([tool, assistant(1, 1)]) expect(stats.steps).toBe(1) @@ -104,7 +104,7 @@ describe('deriveStats', () => { } const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [], - isError: false, callView: null, resultView: null, + isError: false, callView: null, resultView: null, subCalls: [], } const stats = deriveStats([timed, untimed, tool]) expect(stats.llmMs).toBe(2_500) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index c828eeaeb8..38d643292e 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -37,7 +37,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -88,10 +88,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], }) const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, + callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [], }) const command = (over: Partial = {}): CommandNode => ({ kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index eacd2a754a..a89f724dd4 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -47,7 +47,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -126,18 +126,28 @@ describe('render branch tails', () => { expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() }) - it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => { + it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => { localStorage.clear() const snap = snapshotBase() const longText = 'x'.repeat(1_000) - snap.codeDispatches = new Map([['p1', [{ + snap.runningCalls = [{ + callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1, + time: 7_000, callView: null, subCalls: [{ kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', - call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, + call: { name: 'run_code', argsRaw: '{"code":"return 1"}' }, callTime: 8_000, - content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null, - }]]]) + content: [], isError: false, callView: null, resultView: null, + subCalls: [{ + kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1', + call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, + callTime: 8_500, + content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null, + subCalls: [], + }], + }], + }] const chat = createChatStore().create() - chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget) + chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ @@ -168,7 +178,7 @@ describe('render branch tails', () => { expect(view.getByTestId('tool-details-seat')).toBeTruthy() expect(owners).toHaveLength(1) expect(owners[0]?.block).toMatchObject({ - callId: 'p1:code:1', + callId: 'p1:code:1:code:1', call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, content: [{ type: 'text', text: longText }], }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..47791a6090 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -35,7 +35,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index be385d41d5..f197528a2b 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -26,7 +26,7 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 58acf3a863..c47b726b66 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 8c04651e77..826abaf846 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ea35284ad8..70b6d96710 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -70,7 +70,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 845e6099c8..8a7514d644 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -37,7 +37,7 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], }) const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ ...toolResult(seq, callId, 'write'), diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx index 05b84ceda5..431055101c 100644 --- a/packages/client/ui-skill/tests/skill-row.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -28,13 +28,14 @@ function settled(over: Partial = {}): ToolResultNode { isError: false, callView: null, resultView: null, + subCalls: [], ...over, } } function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall { return { - callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, + callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [], } } diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 79a4eef640..5407a43717 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: bf6213ebfacd8f7963463b2c443524c631c28bcd -README.zh.md: 06a46a525eead005375bcf67794a1ceecde678bc +README.md: bab3f92e4f6780b041cee2e682cb8ece3386ef15 +README.zh.md: 9187510fa354ba072835271d4b1153c8ee1105c0 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index bf6213ebfa..bab3f92e4f 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. -Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and `codeDispatches`; the conversation view remains authoritative for ChatFlow placement. +Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and recursive `subCalls` projection; the conversation view remains authoritative for ChatFlow placement. ## Rendering contract -`ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data. +`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection. @@ -44,6 +44,5 @@ None. The package is client-only presentation. ## Known Limitations and Deferred Work -- The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology. - Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot. - Tool copy temporarily reuses the `ui-conversation` locale namespace. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 06a46a525e..9187510fa3 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -4,11 +4,11 @@ Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 -业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和 `codeDispatches`;conversation view 继续负责 ChatFlow 位置。 +业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和递归 `subCalls` 投影;conversation view 继续负责 ChatFlow 位置。 ## 渲染契约 -`ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child,因此 renderer 保留该形状,不自行发明递归数据。 +`ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准 call block,让 root 与任意深度的 child 经过同一条原子分发路径,不再订阅独立的 parent-to-children map。 每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 契约,供分页和 selection 使用。 @@ -44,6 +44,5 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ## 已知限制与后续工作 -- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。 - 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。 - Tool 文案暂时复用 `ui-conversation` locale namespace。 diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 3c71e2f4d4..278be7dacf 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -1,12 +1,12 @@ /** Root/subcall Tool composition with one keyed atomic dispatch path. */ import { memo, useMemo, type ReactNode } from 'react' -import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' import { GenericToolCard } from './toolviews/GenericToolCard.tsx' import css from './ToolCallTree.module.css' -/** Resolve a Code Dispatch child's wire Tool name from either lifecycle form. */ -function subCallName(node: CodeSubCall): string { +/** Resolve a Tool call's wire name from either lifecycle form. */ +function callName(node: ToolCallBlock): string { return 'kind' in node ? node.call?.name ?? '' : node.name } @@ -44,40 +44,33 @@ const ToolCall = memo(function ToolCall({ ) }) -/** - * Render one root Tool call and its currently supported one-level Code - * Dispatch children. Root and children use the same atomic keyed dispatch. - * @param props - whole-Tool owner data and the Tool-owned child-slot share. - * @returns the Tool call tree. - */ -export function ToolCallTree({ - useSession, renderSlot, callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, t, -}: ToolTreeProps) { - const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId)) +const ToolCallBranch = memo(function ToolCallBranch({ + renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t, +}: Pick & { + block: ToolCallBlock +}) { return ( - {subCalls !== undefined && subCalls.length > 0 ? ( + {block.subCalls.length > 0 ? (
- {subCalls.map(node => ( - ( + @@ -86,4 +79,26 @@ export function ToolCallTree({ ) : null} ) +}) + +/** + * Render one root Tool call and its recursive children through the same + * atomic keyed dispatch. + * @param props - whole-Tool owner data and the Tool-owned child-slot share. + * @returns the Tool call tree. + */ +export function ToolCallTree({ + renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t, +}: ToolTreeProps) { + return ( + + ) } diff --git a/packages/client/ui-tool/tests/ask-question-row.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.spec.tsx index f3ab8fd3fd..e6b91823c5 100644 --- a/packages/client/ui-tool/tests/ask-question-row.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.spec.tsx @@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial - ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null }) + ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] }) // Standard locale seat stub mirroring the real ns → common → key chain. const t = makeTranslate(zh, commonZh) diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx index dd733ba323..9cbdfe3f1d 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx @@ -40,7 +40,7 @@ const todoResult = (seq: number): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`, call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) }, callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], }) const bashResult = (seq: number, callId: string, over?: Partial): ToolResultNode => ({ @@ -50,6 +50,7 @@ const bashResult = (seq: number, callId: string, over?: Partial) content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false, callView: { card: 'terminal', title: 'ls -la', description: 'List files' }, resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 }, + subCalls: [], ...over, }) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 8a1df2fd7b..2bc97425e2 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -13,8 +13,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { - CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, - ToolResultNode, WorkspaceListState, + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, + ToolCallBlock, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -48,27 +48,33 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({ call: { name: 'run_code', argsRaw: RUN_CODE_ARGS }, callTime: seq * 1_000 - 500, content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null, + subCalls: [], }) const runningCode = (callId: string): RunningToolCall => ({ callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null, + subCalls: [], }) -const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({ +const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): ToolCallBlock => ({ kind: 'tool-result', seq, time: seq * 1_000, callId: `${parent}:code:${n}`, call: { name, argsRaw: JSON.stringify(args) }, callTime: seq * 1_000, content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null, + subCalls: [], }) function snapshotWith( nodes: ToolResultNode[], - codeDispatches: ReadonlyMap, + subCalls: readonly ToolCallBlock[], runningCalls: RunningToolCall[] = [], ): ConversationSnapshot { + const nestedNodes = nodes.map(node => ({ ...node, subCalls })) + const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) return { - sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches, + sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, + runningCalls: nestedRunningCalls, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, @@ -165,11 +171,11 @@ function mountApp(slots: SlotsService) { describe('run_code sub-calls through the real chat machinery', () => { it('renders the code-variant parent row with the description summary and nested sub-rows', async () => { const parent = 'call-64' - const dispatches = new Map([[parent, [ + const subCalls = [ subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'), - ]]]) - const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + ] + const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) const view = mountApp(b.slots) // Parent row: the code variant with the model-authored description. @@ -193,12 +199,12 @@ describe('run_code sub-calls through the real chat machinery', () => { it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => { const parent = 'call-cordis' const code = 'return { name: "audit", apply(ctx) {} }' - const dispatches = new Map([[parent, [ + const subCalls = [ subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'), subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'), - ]]]) - const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + ] + const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) const view = mountApp(b.slots) const nest = view.container.querySelector('[data-subcalls]')! @@ -214,7 +220,7 @@ describe('run_code sub-calls through the real chat machinery', () => { it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' - const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) + const b = await bench(snapshotWith([codeResult(10, parent)], [])) const view = mountApp(b.slots) // The code row is expandable via the whole summary row (body = the program). const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]') @@ -230,10 +236,10 @@ describe('run_code sub-calls through the real chat machinery', () => { it('an isError sub-call renders the error state dot exactly like a failed native row', async () => { const parent = 'call-64' - const dispatches = new Map([[parent, [ + const subCalls = [ subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true), - ]]]) - const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + ] + const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) const view = mountApp(b.slots) const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]') expect(nested).not.toBeNull() @@ -241,11 +247,11 @@ describe('run_code sub-calls through the real chat machinery', () => { it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => { const parent = 'call-64' - const dispatches = new Map([[parent, [ + const subCalls = [ subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'), subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), - ]]]) - const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + ] + const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) const view = mountApp(b.slots) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() @@ -258,10 +264,10 @@ describe('run_code sub-calls through the real chat machinery', () => { it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => { const parent = 'call-live' - const dispatches = new Map([[parent, [ + const subCalls = [ subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), - ]]]) - const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) + ] + const b = await bench(snapshotWith([], subCalls, [runningCode(parent)])) const view = mountApp(b.slots) const running = view.container.querySelector('[data-variant="code"][data-state="running"]') expect(running).not.toBeNull() @@ -272,12 +278,11 @@ describe('run_code sub-calls through the real chat machinery', () => { it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => { const parent = 'call-live' - const runningSub: CodeSubCall = { + const runningSub: ToolCallBlock = { callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}', - turn: 0, step: 0, time: 21_000, callView: null, + turn: 0, step: 0, time: 21_000, callView: null, subCalls: [], } - const dispatches = new Map([[parent, [runningSub]]]) - const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) + const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)])) const view = mountApp(b.slots) // The nested row derives 'running' from the RunningToolCall shape — the // same data-state chrome (row sweep) a native in-flight row wears. @@ -291,9 +296,9 @@ describe('run_code sub-calls through the real chat machinery', () => { kind: 'tool-result', seq: 10, time: 10_000, callId: parent, call: { name: 'mystery', argsRaw: '{"n":1}' }, callTime: 9_500, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], } - const b = await bench(snapshotWith([plain], new Map())) + const b = await bench(snapshotWith([plain], [])) const view = mountApp(b.slots) expect(view.container.querySelector('[data-subcalls]')).toBeNull() }) diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index 024f97ce76..ab1a0a9632 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -57,7 +57,7 @@ describe('Tool presentation tails', () => { kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, callTime: 1_000, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], } const props: GenericToolCardProps = { callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, @@ -72,7 +72,7 @@ describe('Tool presentation tails', () => { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, callTime: 2_000, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], } const view = render() const row = view.container.querySelector('[data-sample="bash"]')! @@ -84,13 +84,13 @@ describe('Tool presentation tails', () => { it('BashRow carries data-state for running and StateDots for error/stopped', () => { const running: RunningToolCall = { callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', - turn: 1, step: 1, time: 1_000, callView: null, + turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], } const errorResult: ToolResultNode = { kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"boom"}' }, callTime: 500, - content: [], isError: true, callView: null, resultView: null, + content: [], isError: true, callView: null, resultView: null, subCalls: [], } const stoppedResult: ToolResultNode = { ...errorResult, diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index a0dee040c1..7713bedaec 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -48,7 +48,7 @@ const resultDiff = (over?: Partial>): const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'edit', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over, + turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over, }) const settled = (over?: Partial): ToolResultNode => ({ @@ -56,7 +56,7 @@ const settled = (over?: Partial): ToolResultNode => ({ call: { name: 'edit', argsRaw: ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, - callView: callDiff(), resultView: resultDiff(), ...over, + callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over, }) describe('diffCardModel', () => { @@ -343,7 +343,7 @@ describe('DetailsPanel diff Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 826bd72892..739a787a03 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -59,7 +59,7 @@ const resultRead = (over?: Partial>): const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'read', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over, }) const settled = (over?: Partial): ToolResultNode => ({ @@ -67,7 +67,7 @@ const settled = (over?: Partial): ToolResultNode => ({ call: { name: 'read', argsRaw: ARGS }, callTime: 1_000, content: [{ type: 'text', text: '41: export const a = 1' }], isError: false, - callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over, + callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over, }) describe('readCardModel', () => { @@ -289,7 +289,7 @@ describe('DetailsPanel Output section (read)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 72e25ac32b..51cab2d440 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -65,7 +65,7 @@ const resultPaths = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, - turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over, }) const settledGrep = (over?: Partial): ToolResultNode => ({ @@ -73,7 +73,7 @@ const settledGrep = (over?: Partial): ToolResultNode => ({ call: { name: 'grep', argsRaw: GREP_ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false, - callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over, + callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over, }) const settledGlob = (over?: Partial): ToolResultNode => ({ @@ -81,7 +81,7 @@ const settledGlob = (over?: Partial): ToolResultNode => ({ call: { name: 'glob', argsRaw: GLOB_ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false, - callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over, + callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over, }) describe('searchCardModel', () => { @@ -405,7 +405,7 @@ describe('DetailsPanel Output section (search)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index 93a538c5c0..c9e2875a34 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -58,7 +58,7 @@ const resultTerminal = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over, + turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over, }) const settled = (over?: Partial): ToolResultNode => ({ @@ -66,7 +66,7 @@ const settled = (over?: Partial): ToolResultNode => ({ call: { name: 'bash', argsRaw: ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false, - callView: callTerminal(), resultView: resultTerminal(), ...over, + callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over, }) describe('terminalCardModel', () => { @@ -480,7 +480,7 @@ describe('DetailsPanel Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, @@ -568,15 +568,17 @@ describe('DetailsPanel Output section', () => { // pins the resolution path with views injected directly, and the arm below // pins what the shipped path actually shows today. it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => { + const child = settled({ callId: 'c1' }) const view = mount(snapshot({ - codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]), + runningCalls: [running({ callId: 'p1', subCalls: [child] })], }), target) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() }) it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => { + const child = settled({ callId: 'c1', callView: null, resultView: null }) const view = mount(snapshot({ - codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]), + runningCalls: [running({ callId: 'p1', subCalls: [child] })], }), target) // No terminal card: the generic path renders the result text in the Output // section's
 (the Input section has its own, hence the scoping).
@@ -588,7 +590,10 @@ describe('DetailsPanel Output section', () => {
   it('a running run_code sub-dispatch resolves through the running material', () => {
     const view = mount(snapshot({
       // The leading non-matching sub-call exercises the scan's skip.
-      codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
+      runningCalls: [running({
+        callId: 'p1',
+        subCalls: [running({ callId: 'other' }), running()],
+      })],
     }), target)
     expect(view.getByText('ls -la')).toBeTruthy()
   })
diff --git a/packages/client/ui-tool/tests/todo-row.spec.tsx b/packages/client/ui-tool/tests/todo-row.spec.tsx
index ef3253a947..db720ed056 100644
--- a/packages/client/ui-tool/tests/todo-row.spec.tsx
+++ b/packages/client/ui-tool/tests/todo-row.spec.tsx
@@ -61,7 +61,7 @@ describe('planSummary', () => {
 const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({
   kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
   call: { name: 'todo_write', argsRaw },
-  content: [], isError: false, callView: null, resultView: null, ...over,
+  content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
 })
 
 function rowProps(block: unknown): TodoRowProps {
@@ -106,7 +106,7 @@ describe('TodoRow', () => {
 
   it('keeps non-ok execution states visible through the shared row states', () => {
     const args = JSON.stringify({ todos: LIST })
-    const running = render()
+    const running = render()
     expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
     expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
     running.unmount()
diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx
index c8c31ad365..393b9f8f46 100644
--- a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx
+++ b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx
@@ -2,7 +2,7 @@
 /** ToolCallTree-owned root/subcall markers and selection projection. */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { ToolTreeProps } from '../src/client/contract/slots.ts'
@@ -15,15 +15,14 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
 
 const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
   kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
-  content: [], isError: false, callView: null, resultView: null,
+  content: [], isError: false, callView: null, resultView: null, subCalls: [],
 })
 
 function props(
   block: ToolResultNode,
-  codeDispatches: ReadonlyMap = new Map(),
   selectedCallId?: string,
 ): ToolTreeProps {
-  const snapshot = { codeDispatches } as ConversationSnapshot
+  const snapshot = {} as ConversationSnapshot
   const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
   const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
     options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
@@ -43,7 +42,7 @@ function props(
 describe('ToolCallTree', () => {
   it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
     const block = root('w1', null)
-    const view = render()
+    const view = render()
     const row = view.container.querySelector('[data-chat-call-id="w1"]')
     expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
     expect(row?.getAttribute('data-selected')).toBe('true')
@@ -51,15 +50,23 @@ describe('ToolCallTree', () => {
     expect(view.getByText('w1')).toBeTruthy()
   })
 
-  it('marks a selected subcall without selecting its root', () => {
-    const block = root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' })
-    const child: CodeSubCall = root('parent:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
-    const view = render(
-      ,
-    )
-    expect(view.container.querySelector('[data-subcalls]')?.parentElement)
-      .toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
+  it('recursively renders a selected leaf without selecting its ancestors', () => {
+    const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
+    const child = {
+      ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
+      subCalls: [leaf],
+    }
+    const block = {
+      ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
+      subCalls: [child],
+    }
+    const view = render()
+    const nests = view.container.querySelectorAll('[data-subcalls]')
+    expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
+    expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
     expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
-    expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true')
+    expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
+    expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
+    expect(nests).toHaveLength(2)
   })
 })
diff --git a/packages/client/ui-tool/tests/tool-row.spec.tsx b/packages/client/ui-tool/tests/tool-row.spec.tsx
index 02189e3634..9fed8e3720 100644
--- a/packages/client/ui-tool/tests/tool-row.spec.tsx
+++ b/packages/client/ui-tool/tests/tool-row.spec.tsx
@@ -20,14 +20,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
 
 const running = (over?: Partial): RunningToolCall => ({
   callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
-  turn: 1, step: 1, time: 1_000, callView: null, ...over,
+  turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over,
 })
 
 const result = (over?: Partial): ToolResultNode => ({
   kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
   call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
   callTime: 1_000,
-  content: [], isError: false, callView: null, resultView: null, ...over,
+  content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
 })
 
 describe('tool-call-model', () => {
diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.spec.tsx
index 846f81cc45..e96c460576 100644
--- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx
+++ b/packages/client/ui-tool/tests/toolview-slot.spec.tsx
@@ -42,7 +42,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
   kind: 'tool-result', seq, time: seq * 1_000, callId,
   call: { name, argsRaw: args },
   callTime: seq * 1_000 - 500,
-  content: [], isError: false, callView: null, resultView: null,
+  content: [], isError: false, callView: null, resultView: null, subCalls: [],
 })
 
 /** Test-owned AppFrame role: declares and renders the resident conversation area. */
diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx
index b8a5a4e06c..7856ae9d6f 100644
--- a/packages/client/ui-tool/tests/web-card.spec.tsx
+++ b/packages/client/ui-tool/tests/web-card.spec.tsx
@@ -56,7 +56,7 @@ const resultFetch = (over?: Partial): RunningToolCall => ({
   callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
-  turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
+  turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over,
 })
 
 const settledSearch = (over?: Partial): ToolResultNode => ({
@@ -64,7 +64,7 @@ const settledSearch = (over?: Partial): ToolResultNode => ({
   call: { name: 'web_search', argsRaw: SEARCH_ARGS },
   callTime: 1_000,
   content: [{ type: 'text', text: 'search text' }], isError: false,
-  callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
+  callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over,
 })
 
 const settledFetch = (over?: Partial): ToolResultNode => ({
@@ -72,7 +72,7 @@ const settledFetch = (over?: Partial): ToolResultNode => ({
   call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
   callTime: 1_000,
   content: [{ type: 'text', text: 'fetch body' }], isError: false,
-  callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
+  callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over,
 })
 
 describe('webCardModel', () => {
@@ -235,7 +235,7 @@ describe('DetailsPanel web Output section', () => {
 
   function snapshot(over: Partial = {}): ConversationSnapshot {
     return {
-      sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
+      sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
       pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
       openState: 'open', openError: null, hasMore: false, loadingOlder: false,
       promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx
index 81b4f9d7f0..ff63b860b9 100644
--- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx
+++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx
@@ -211,7 +211,6 @@ export function TrajectoryView({
   const nodes = inspection.eventNodes
   const partial = inspection.partial
   const runningCalls = inspection.runningCalls
-  const codeDispatches = inspection.codeDispatches
   const loadHistoryTailRef = useRef(loadHistoryTail)
   loadHistoryTailRef.current = loadHistoryTail
   const historyControllerRef = useRef(null)
@@ -366,12 +365,11 @@ export function TrajectoryView({
       runningCalls,
       requests: selectedRequests,
       callSchemas,
-      codeDispatches,
     })
     return { turns, lastIndex: lastCellIndex(turns) }
   }, [
     selectedNodes, partialTurn, partialStep,
-    runningCalls, selectedRequests, callSchemas, codeDispatches,
+    runningCalls, selectedRequests, callSchemas,
   ])
   const timelinePartialSignature = partialStructureSignature(partial)
   const timelinePartial = useMemo(() => partial === null
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts
index 273b4fc32a..6853199aa1 100644
--- a/packages/client/ui-trajectory/src/client/layout.ts
+++ b/packages/client/ui-trajectory/src/client/layout.ts
@@ -5,11 +5,11 @@
 import type {
   AssistantBlock,
   AssistantMessageNode,
-  CodeSubCall,
   ConversationSnapshot,
   RequestInspectionSnapshot,
   RequestPromptChange,
   RequestView,
+  ToolCallBlock,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -39,8 +39,6 @@ export interface TrajectoryLayoutInput {
   runningCalls: ConversationSnapshot['runningCalls']
   requests?: readonly RequestView[]
   callSchemas?: RequestInspectionSnapshot['callSchemas']
-  /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
-  codeDispatches: ConversationSnapshot['codeDispatches']
 }
 
 interface UsageLike {
@@ -57,6 +55,7 @@ interface LaidCell {
   absTime: number | null
   toolName?: string
   callId?: string
+  subCalls?: readonly ToolCallBlock[]
 }
 
 interface LaidGroup {
@@ -131,9 +130,11 @@ function inputCellDetail(node: InputNode): Pick<
  */
 export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
   const {
-    nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
+    nodes, partial, runningCalls, requests = [], callSchemas,
   } = input
   const resultByCall = indexResults(nodes)
+  const callById = new Map(resultByCall)
+  for (const call of runningCalls) callById.set(call.callId, call)
   const emittedCallIds = indexAssistantCallIds(nodes)
   const callStartById = new Map()
   for (const result of resultByCall.values()) {
@@ -344,8 +345,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     }
     if (node.kind === 'assistant') {
       const laidList = withSubCalls(
-        expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
-        codeDispatches,
+        expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
       )
       if (node.step > 0) pushStep(node.turn, node.step, laidList)
       else for (const laid of laidList) pushMessage(node.turn, laid)
@@ -381,6 +381,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
           absTime: finiteTime(node.callTime ?? node.time),
           ...(toolName !== undefined ? { toolName } : {}),
           callId: node.callId,
+          subCalls: node.subCalls,
           cell: {
             index: ++index,
             kind: 'tool',
@@ -398,7 +399,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
             startedAt: finiteTime(node.callTime),
           },
         }]
-        for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
+        for (const laid of expandSubCalls(node.subCalls, index)) {
           laidList.push(laid)
           index = laid.cell.index
         }
@@ -413,14 +414,15 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
       turn: partial.turn, step: partial.step, blocks: partial.blocks,
     }
-    const laidList = expandAssistant(
+    const laidList = withSubCalls(expandAssistant(
       fake,
       index + 1,
       prevAbsTime,
       resultByCall,
       callStartById,
+      callById,
       { streaming: true },
-    )
+    ))
     if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
     else for (const laid of laidList) pushMessage(partial.turn, laid)
     const last = laidList[laidList.length - 1]
@@ -434,6 +436,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       absTime: null,
       toolName: call.name,
       callId: call.callId,
+      subCalls: call.subCalls,
       cell: {
         index: ++index,
         kind: 'tool',
@@ -444,7 +447,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
         startedAt: finiteTime(call.time),
       },
     }]
-    for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
+    for (const laid of expandSubCalls(call.subCalls, index)) {
       laidList.push(laid)
       index = laid.cell.index
     }
@@ -491,7 +494,6 @@ export function appendTrajectoryPartialLayout(
     nodes: [],
     partial,
     runningCalls: [],
-    codeDispatches: new Map(),
   }).at(0)
   if (partialTurn === undefined) return turns
   const streamed: TrajectoryTurnModel = {
@@ -622,6 +624,7 @@ function expandAssistant(
   prevAbsTime: number | null,
   results: Map,
   callStarts: ReadonlyMap,
+  calls: ReadonlyMap,
   opts?: { streaming?: boolean },
 ): LaidCell[] {
   if (opts?.streaming === true && node.blocks.length === 0) return []
@@ -677,10 +680,12 @@ function expandAssistant(
       ? null
       : durationSeconds(result.time, result.callTime)
     const callAbs = finiteTime(callStarts.get(block.callId))
+    const call = calls.get(block.callId)
     out.push({
       absTime: callAbs,
       toolName: block.name,
       callId: block.callId,
+      ...(call === undefined ? {} : { subCalls: call.subCalls }),
       cell: {
         index: ++index, kind: 'tool',
         text: summarizeCall(block.name, block.argsRaw),
@@ -883,15 +888,14 @@ function collectCallIds(
 
 
 
-/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
-function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
-  if (codeDispatches.size === 0) return laidList
+/** Interleave each tool cell's nested child calls right after it, reindexing followers. */
+function withSubCalls(laidList: LaidCell[]): LaidCell[] {
+  if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList
   const out: LaidCell[] = []
   let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
   for (const laid of laidList) {
     out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
-    if (laid.callId === undefined) continue
-    for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
+    for (const sub of expandSubCalls(laid.subCalls, index)) {
       out.push(sub)
       index = sub.cell.index
     }
@@ -901,7 +905,7 @@ function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot
 
 /** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
 function expandSubCalls(
-  subs: readonly CodeSubCall[] | undefined,
+  subs: readonly ToolCallBlock[] | undefined,
   startIndex: number,
 ): LaidCell[] {
   if (subs === undefined || subs.length === 0) return []
@@ -909,10 +913,11 @@ function expandSubCalls(
   let index = startIndex
   for (const sub of subs) {
     const settled = 'kind' in sub
-    out.push({
+    const laid: LaidCell = {
       absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
       toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
       callId: sub.callId,
+      subCalls: sub.subCalls,
       cell: {
         index: ++index,
         kind: 'subtool',
@@ -938,7 +943,12 @@ function expandSubCalls(
           ? finiteTime(sub.callTime)
           : finiteTime(sub.time),
       },
-    })
+    }
+    out.push(laid)
+    for (const child of expandSubCalls(sub.subCalls, index)) {
+      out.push(child)
+      index = child.cell.index
+    }
   }
   return out
 }
diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx
index 544199e345..4c49b07907 100644
--- a/packages/client/ui-trajectory/tests/layout.spec.tsx
+++ b/packages/client/ui-trajectory/tests/layout.spec.tsx
@@ -74,7 +74,7 @@ describe('deriveTrajectoryLayout', () => {
         content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
       },
     ] as unknown as ConversationSnapshot['nodes']
-    const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     expect(turns).toHaveLength(1)
     expect(turns[0]?.turn).toBe(1)
     const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
@@ -90,12 +90,11 @@ describe('deriveTrajectoryLayout', () => {
 
   it('adds runningCalls not already present and leaves their time blank', () => {
     const turns = deriveTrajectoryLayout({
-      codeDispatches: new Map(),
       nodes: [],
       partial: null,
       runningCalls: [{
         callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
-        turn: 1, step: 2, time: 9_000, callView: null,
+        turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
       }],
     })
     expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
@@ -119,7 +118,6 @@ describe('deriveTrajectoryLayout', () => {
       startedAt: 3_000, completedAt: null, status: 'running',
     } as unknown as RequestView
     const base = deriveTrajectoryLayout({
-      codeDispatches: new Map(),
       nodes,
       partial: { ...partial, blocks: [] },
       requests: [request],
@@ -152,12 +150,11 @@ describe('deriveTrajectoryLayout', () => {
       }],
     }
     const base = deriveTrajectoryLayout({
-      codeDispatches: new Map(),
       nodes: [],
       partial: { ...partial, blocks: [] },
       runningCalls: [{
         callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
-        turn: 1, step: 1, time: 9_000, callView: null,
+        turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
       }],
     })
 
@@ -180,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
         usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
       },
     ] as unknown as ConversationSnapshot['nodes']
-    const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
     expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
     expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
@@ -206,7 +203,7 @@ describe('deriveTrajectoryLayout', () => {
         content: [], isError: false, callView: null, resultView: null,
       },
     ] as unknown as ConversationSnapshot['nodes']
-    const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
   })
 
@@ -223,7 +220,7 @@ describe('deriveTrajectoryLayout', () => {
         blocks: [{ kind: 'text', text: 'ok2' }],
       },
     ] as unknown as ConversationSnapshot['nodes']
-    const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     expect(turns.map(t => t.turn)).toEqual([1, 2])
     expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
     expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
@@ -254,7 +251,6 @@ describe('deriveTrajectoryLayout', () => {
     }
 
     const turns = deriveTrajectoryLayout({
-      codeDispatches: new Map(),
       nodes,
       partial: null,
       runningCalls: [],
@@ -280,7 +276,7 @@ describe('deriveTrajectoryLayout', () => {
         usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
       },
     ] as unknown as ConversationSnapshot['nodes']
-    const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
     expect(message).toMatchObject({
       text: '…', input: 11, output: 22, think: 3,
@@ -295,7 +291,7 @@ describe('deriveTrajectoryLayout', () => {
     }] as unknown as ConversationSnapshot['nodes']
 
     const turns = deriveTrajectoryLayout({
-      codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
+      nodes, partial: null, runningCalls: [],
     })
     const message = turns[0]?.groups.flatMap(group => group.cells)
       .find(cell => cell.kind === 'message')
@@ -333,7 +329,7 @@ describe('deriveTrajectoryLayout', () => {
         blocks: [{ kind: 'text', text: 'done' }],
       },
     ] as unknown as ConversationSnapshot['nodes']
-    const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
     const message = cells.find(c => c.kind === 'message' && c.text === 'done')
     // From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
@@ -352,7 +348,7 @@ describe('deriveTrajectoryLayout', () => {
       },
     ] as unknown as ConversationSnapshot['nodes']
     const turns = deriveTrajectoryLayout({
-      codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
+      nodes, partial: null, runningCalls: [],
     })
     const message = turns[0]?.groups.flatMap(group => group.cells)
       .find(cell => cell.kind === 'message')
@@ -372,6 +368,7 @@ describe('run_code sub-dispatch cells', () => {
       kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
       call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
       content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
+      subCalls: [],
     },
   ] as unknown as ConversationSnapshot['nodes']
 
@@ -380,14 +377,18 @@ describe('run_code sub-dispatch cells', () => {
     callId: `p1:code:${n}`,
     call: { name, argsRaw: '{"x":1}' }, callTime: start,
     content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
+    subCalls: [],
   })
 
+  const withSubCalls = (subCalls: readonly ReturnType[] | readonly object[]) =>
+    runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes']
+
   it('nests settled sub-cells after their parent Tool cell with real durations', () => {
-    const codeDispatches = new Map([['p1', [
+    const subCalls = [
       settledSub(1, 'bash', 6_300, 7_300),
       settledSub(2, 'read', 7_300, 7_800),
-    ]]]) as unknown as ConversationSnapshot['codeDispatches']
-    const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
+    ]
+    const turns = deriveTrajectoryLayout({ nodes: withSubCalls(subCalls), partial: null, runningCalls: [] })
     const cells = turns[0]!.groups.flatMap(g => g.cells)
     expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
     expect(cells[0]?.text).toBe('Tool call only')
@@ -400,11 +401,29 @@ describe('run_code sub-dispatch cells', () => {
   it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
     const running = {
       callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
-      turn: 0, step: 0, time: 6_400, callView: null,
+      turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
     }
-    const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
-    const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
+    const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
     const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
     expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
   })
+
+  it('recursively flattens nested child calls immediately after their parent', () => {
+    const leaf = {
+      ...settledSub(2, 'read', 7_300, 7_800),
+      callId: 'p1:code:1:code:1',
+    }
+    const child = {
+      ...settledSub(1, 'run_code', 6_300, 8_000),
+      subCalls: [leaf],
+    }
+    const turns = deriveTrajectoryLayout({ nodes: withSubCalls([child]), partial: null, runningCalls: [] })
+    const cells = turns[0]!.groups.flatMap(group => group.cells)
+    expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
+    expect(cells.slice(2).map(cell => cell.callId)).toEqual([
+      'p1:code:1',
+      'p1:code:1:code:1',
+    ])
+    expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
+  })
 })
diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx
index 56a9ee90cf..2a0ec68ec0 100644
--- a/packages/client/ui-trajectory/tests/views.spec.tsx
+++ b/packages/client/ui-trajectory/tests/views.spec.tsx
@@ -82,7 +82,6 @@ function historySnapshot(
       interruptedNodes: [],
       partial: null,
       runningCalls: [],
-      codeDispatches: new Map(),
       ...inspection,
     },
   }
@@ -115,7 +114,7 @@ function standaloneDuration(): Pick<
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
   const store = createSnapshotStore({
     nodes, pending: [], partial: null,
-    runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
+    runningCalls: [] as ConversationSnapshot['runningCalls'],
   })
   return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
 }
@@ -186,7 +185,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
     running: false, removed: false, promptError: null, nodes,
     pending: [],
     openState: 'open' as const, hasMore: true, loadingOlder: false,
-    partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
+    partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
   })
   const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession
   const chat = createChatStore().create()

From 091b9931984581278e2b8f28547dd912026b68cd Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Sat, 8 Aug 2026 17:46:50 +0800
Subject: [PATCH 009/262] fix(client): harden nested Tool call projection

---
 ...7-19-gui-web-client-architecture.i18n.yaml |  4 +-
 .../2026-07-19-gui-web-client-architecture.md |  2 +-
 ...26-07-19-gui-web-client-architecture.zh.md |  2 +-
 ...7-26-code-mode-chat-subcall-rows.i18n.yaml |  4 +-
 .../2026-07-26-code-mode-chat-subcall-rows.md | 10 +--
 ...26-07-26-code-mode-chat-subcall-rows.zh.md | 10 +--
 ...code-mode-live-parallel-dispatch.i18n.yaml |  4 +-
 ...-07-26-code-mode-live-parallel-dispatch.md |  2 +-
 ...-26-code-mode-live-parallel-dispatch.zh.md |  2 +-
 .../src/client/sessions/tool-call-tree.ts     | 43 +++++++-----
 .../client/runtime/tests/history-fold.spec.ts | 31 +++++++++
 .../runtime/tests/tool-call-tree.spec.ts      | 65 +++++++++++++++++++
 .../tests/gate-branch-tails.spec.tsx          | 22 +++----
 packages/client/ui-tool/README.i18n.yaml      |  4 +-
 packages/client/ui-tool/README.md             |  1 +
 packages/client/ui-tool/README.zh.md          |  1 +
 .../ui-tool/tests/chat-code-subcalls.spec.tsx |  4 +-
 .../client/ui-trajectory/src/client/layout.ts |  1 -
 18 files changed, 160 insertions(+), 52 deletions(-)
 create mode 100644 packages/client/runtime/tests/tool-call-tree.spec.ts

diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
index 2357aa30f2..d3bbc7da9d 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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/architecture/2026-07-19-gui-web-client-architecture.md
-2026-07-19-gui-web-client-architecture.md: cba029ea74e89b277f9079ebb3765deeeb105b47
-2026-07-19-gui-web-client-architecture.zh.md: f700cc04fe1479d43957456c14b3abb4014d99c5
+2026-07-19-gui-web-client-architecture.md: e25d002b92df2016faf4073108aa905b65a1b115
+2026-07-19-gui-web-client-architecture.zh.md: cf31220aad61e4845f7fe90b73bf5da4432e53de
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
index cba029ea74..e25d002b92 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
@@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
 
 A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
 
-There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: ui-conversation places each ordered root call into the single `'conversation.chat.tool'` seat without interpreting Tool names or Code Dispatch topology; ui-tool selects `codeDispatches[rootCallId]` from the Runtime snapshot, renders that root/child shape, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and both roots and children dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components.
+There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: Runtime projects Code Dispatch topology into each root's recursive `subCalls`; ui-conversation places that ordered root into the single `'conversation.chat.tool'` seat without interpreting Tool names or topology; ui-tool renders the supplied tree and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components.
 
 **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
 
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
index f700cc04fe..cf31220aad 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
@@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
 
 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
 
-slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:ui-conversation 把每个已排序 root call 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或 Code Dispatch 拓扑;ui-tool 从 Runtime snapshot 选择 `codeDispatches[rootCallId]`、渲染 root/child 形状,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与 child 都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。
+slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:运行时把 Code Dispatch 拓扑投影进每个 root 递归的 `subCalls`;ui-conversation 把这个已排序 root 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或拓扑;ui-tool 渲染传入的树,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。
 
 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
 
diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml
index d550ad1fb6..390144b4bd 100644
--- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.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-07-26-code-mode-chat-subcall-rows.md
-2026-07-26-code-mode-chat-subcall-rows.md: dc09e8fda9dbfb156b6218dec67584ee7bfead75
-2026-07-26-code-mode-chat-subcall-rows.zh.md: d445ddac4704fb940550d27ef7400e79d859393b
+2026-07-26-code-mode-chat-subcall-rows.md: 40b4b8fd8c5cea8e9aea01731d7eceff692db188
+2026-07-26-code-mode-chat-subcall-rows.zh.md: 9981fded054f663dd539660ed2a2eca68a9d3138
diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md
index dc09e8fda9..40b4b8fd8c 100644
--- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md
+++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md
@@ -12,12 +12,12 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog
 
 ## Decision
 
-**Sub-calls are `ToolResultNode`s indexed off the surface flow, rendered through the same keyed slot as native rows, nested always-visible under their parent.**
+**Sub-calls are standard Tool call blocks attached recursively to their parent outside the surface flow, rendered through the same keyed slot as native rows, and always visible under their parent.**
 
-- **Data layer**: `Session.applyEventSideEffects` folds each in-window `tool/code-dispatch` into `ConversationSnapshot.codeDispatches: ReadonlyMap`, where `CodeSubCall` IS `ToolResultNode` (the sub-call id as `callId`, the logged args JSON-stringified into `call.argsRaw`, the full logged `content`/`isError`). Live mux frames and history replay build the identical index (`rebuildDerivedFromWindow` clears and re-derives; copy-on-write per-parent arrays keep snapshot references memo-stable). Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The event is narrowed structurally at the wire-consumer boundary (dsh-tools' host types cannot enter the client program — the host/client `Context` merges collide), the same posture as every cross-wire payload.
-- **Render layer**: `ChatView` passes each parent and its indexed children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by a `[data-subcalls]` nest, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over child and top-level calls without registration changes. Running parents (`runningCalls`) receive their accumulated dispatches through the same owner payload, so child rows stream in during the run.
+- **Data layer**: Runtime's `ToolCallTree` folds in-window `tool/code-dispatch-start` and `tool/code-dispatch` events into a private per-parent index, then projects running and settled children onto recursive `ToolCallBlock.subCalls`. Live Session projection and `projectConversationHistory` share that fold; copy-on-write parent arrays and path-copy projection keep unrelated roots and siblings reference-stable. Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The events are narrowed structurally at the wire-consumer boundary, which also rejects cyclic parent relationships (dsh-tools' host types cannot enter the client program because the host/client `Context` merges collide).
+- **Render layer**: `ChatView` passes each parent with its recursive children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by `[data-subcalls]` nests, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over descendant and top-level calls without registration changes. Running parents (`runningCalls`) receive accumulated dispatches in the same recursive block, so child rows stream in during the run.
 - **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope.
-- **Details panel**: `materialFor` falls through nodes → runningCalls → the dispatch index, so a selected sub-callId resolves to full args and complete output through the identical rendering path as a native settled call.
+- **Details panel**: `materialFor` recursively searches `nodes` and `runningCalls`, so a selected descendant callId resolves to full args and complete output through the identical rendering path as a native settled call.
 
 ## Alternatives considered
 
@@ -29,4 +29,4 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog
 
 ## Consequences
 
-Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership tests both levels). Trajectory/waterfall still render `run_code` as a single row — their sub-call spans are deferred to the PR that adds dispatch timing (start/end events), without which a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom suites pin the slot dispatch, error states, details resolution, and index reference stability.
+Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership searches the whole tree). Trajectory/waterfall still render `run_code` as a single row — their sub-call spans are deferred to the PR that adds dispatch timing (start/end events), without which a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom and Runtime suites pin slot dispatch, error states, recursive details resolution, history projection, and reference-stable path copying.
diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md
index d445ddac47..9981fded05 100644
--- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md
@@ -12,12 +12,12 @@ Status: implemented
 
 ## 决策
 
-**子调用在界面流之外单独索引为 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。**
+**子调用是在 surface 流之外递归附着到父级的标准 Tool 调用块,经由与原生行相同的 keyed slot 渲染,并始终显示在父级之下。**
 
-- **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。实时多路复用帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用稳定,便于 memo 化)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的宿主类型无法进入客户端程序——宿主端/客户端两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。
-- **渲染层**:`ChatView` 通过整体 Tool seat `'conversation.chat.tool'` 传递每个 parent 及其已索引的 child。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染一组 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以 Tool 名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管 child 与顶层调用。运行中的 parent(`runningCalls`)通过同一 owner 载荷接收已累积的 dispatch,使 child 行在运行期间实时流入。
+- **数据层**:运行时的 `ToolCallTree` 把窗口内的 `tool/code-dispatch-start` 与 `tool/code-dispatch` 事件折入私有的逐父级索引,再把运行中和已结算的子级投影到递归的 `ToolCallBlock.subCalls` 上。实时 Session 投影与 `projectConversationHistory` 共享这一折叠过程;逐父级的写时复制数组和路径复制投影让无关 root 与 sibling 保持引用稳定。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。这些事件在 wire 消费方边界作结构性收窄,该边界也会拒绝成环的父子关系(dsh-tools 的宿主类型无法进入客户端程序,因为宿主端与客户端两侧的 `Context` 声明合并会冲突)。
+- **渲染层**:`ChatView` 通过整体 Tool seat `'conversation.chat.tool'` 传递每个 parent 及其递归 child。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以 Tool 名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管任意后代与顶层调用。运行中的 parent(`runningCalls`)在同一个递归块中接收已累积的 dispatch,使 child 行在运行期间实时流入。
 - **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。
-- **详情面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。
+- **详情面板**:`materialFor` 递归搜索 `nodes` 与 `runningCalls`,因此被选中的后代 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。
 
 ## 曾考虑的替代方案
 
@@ -29,4 +29,4 @@ Status: implemented
 
 ## 后果
 
-自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属判断会同时检验两个层级)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 测试套件则锁定 slot 分发、错误状态、详情解析与索引引用稳定性。
+自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属会搜索整棵树)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 与运行时测试套件则锁定 slot 分发、错误状态、递归详情解析、历史投影与引用稳定的路径复制。
diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml
index 2d3d73f65d..ed15fd5c36 100644
--- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.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-07-26-code-mode-live-parallel-dispatch.md
-2026-07-26-code-mode-live-parallel-dispatch.md: b4afc21be902d8ed3e5bee2ad1a540413a864f25
-2026-07-26-code-mode-live-parallel-dispatch.zh.md: b6169e6d243163f8444ffc7b87b741ade7631198
+2026-07-26-code-mode-live-parallel-dispatch.md: e85d1f99f58f1aae28091cd8875ee87a6df5a1e7
+2026-07-26-code-mode-live-parallel-dispatch.zh.md: 39da98d9c3630d549f13df0ea555687614f3065a
diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md
index b4afc21be9..e85d1f99f5 100644
--- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md
+++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md
@@ -16,7 +16,7 @@ Two gaps remained after the first two PRs. Sub-call rows appeared only when each
 
 - **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0.
 - **Bridge scheduler**: submitted calls are classified at start time via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, `prepare` (pre-execute/guards), the head-of-line `finalize`/`finish` commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (`fillPool` awaits `startCall` then `commitReady`). Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a `Config` field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn.
-- **Client**: `CodeSubCall` widens to `RunningToolCall | ToolResultNode` — a start event lands the running shape in the dispatch index (rows derive the running ring from the shape, exactly as for native in-flight calls), and its settle replaces the entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering.
+- **Client**: Runtime's `ToolCallTree` stores a start event as a `RunningToolCall` child and projects it through the parent's recursive `subCalls` (rows derive the running ring from that shape, exactly as for native in-flight calls). Its settle replaces the private-index entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering.
 - **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every code-mode snapshot.
 
 ## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md
index b6169e6d24..39da98d9c3 100644
--- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md
@@ -16,7 +16,7 @@ Status: implemented
 
 - **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件都保持仅日志;模型上下文不受影响;格式保持 v0。
 - **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 契约),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。
-- **客户端侧**:`CodeSubCall` 拓宽为 `RunningToolCall | ToolResultNode`:start 事件把运行中形状写入分发索引(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致),其结算事件则原位替换该条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。
+- **客户端侧**:运行时的 `ToolCallTree` 把 start 事件存为 `RunningToolCall` 子级,并通过父级递归的 `subCalls` 投影出来(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致)。其结算事件会原位替换私有索引中的条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。
 - **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实契约(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 Code Mode 快照都已重新录制。
 
 ## 曾考虑的替代方案
diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/runtime/src/client/sessions/tool-call-tree.ts
index ef25ba3da2..574a10dd92 100644
--- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts
+++ b/packages/client/runtime/src/client/sessions/tool-call-tree.ts
@@ -10,9 +10,9 @@ interface ProjectedBlock {
   value: ToolCallBlock
 }
 
-function sameBlocks(
-  left: readonly ToolCallBlock[],
-  right: readonly ToolCallBlock[],
+function sameReferences(
+  left: readonly T[],
+  right: readonly T[],
 ): boolean {
   return left.length === right.length
     && left.every((block, index) => block === right[index])
@@ -68,6 +68,7 @@ export class ToolCallTree {
         subCalls: [],
       }
       const siblings = this.childrenByParent.get(data.parentCallId) ?? []
+      if (this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
       this.childrenByParent.set(data.parentCallId, [...siblings, running])
       this.revision++
       return true
@@ -83,6 +84,7 @@ export class ToolCallTree {
     }
     const siblings = this.childrenByParent.get(data.parentCallId) ?? []
     const at = siblings.findIndex(sub => sub.callId === data.subCallId)
+    if (at === -1 && this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
     const started = at === -1 ? undefined : siblings[at]
     const settled: ToolResultNode = {
       kind: 'tool-result',
@@ -116,14 +118,11 @@ export class ToolCallTree {
     if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
       return this.nodesCache.value
     }
-    let changed = false
     const projected = nodes.map((node): ConversationNode => {
       if (node.kind !== 'tool-result') return node
-      const value = this.projectBlock(node) as ToolResultNode
-      changed ||= value !== node
-      return value
+      return this.projectBlock(node) as ToolResultNode
     })
-    const value = changed ? projected : nodes
+    const value = sameReferences(nodes, projected) ? nodes : projected
     this.nodesCache = { source: nodes, revision: this.revision, value }
     return value
   }
@@ -137,13 +136,8 @@ export class ToolCallTree {
     if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
       return this.runningCache.value
     }
-    let changed = false
-    const projected = calls.map((call): RunningToolCall => {
-      const value = this.projectBlock(call) as RunningToolCall
-      changed ||= value !== call
-      return value
-    })
-    const value = changed ? projected : calls
+    const projected = calls.map(call => this.projectBlock(call) as RunningToolCall)
+    const value = sameReferences(calls, projected) ? calls : projected
     this.runningCache = { source: calls, revision: this.revision, value }
     return value
   }
@@ -151,11 +145,11 @@ export class ToolCallTree {
   private projectBlock(block: ToolCallBlock): ToolCallBlock {
     const children = this.childrenByParent.get(block.callId) ?? block.subCalls
     const projectedChildren = children.map(child => this.projectBlock(child))
-    const childValue = sameBlocks(children, projectedChildren)
+    const childValue = sameReferences(children, projectedChildren)
       ? children
       : projectedChildren
     const cached = this.projectedByCall.get(block.callId)
-    if (cached?.source === block && sameBlocks(cached.children, childValue)) {
+    if (cached?.source === block && sameReferences(cached.children, childValue)) {
       return cached.value
     }
     const value: ToolCallBlock = block.subCalls === childValue
@@ -168,4 +162,19 @@ export class ToolCallTree {
     })
     return value
   }
+
+  private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
+    if (parentCallId === subCallId) return true
+    const pending = [subCallId]
+    const visited = new Set(pending)
+    for (const callId of pending) {
+      for (const child of this.childrenByParent.get(callId) ?? []) {
+        if (child.callId === parentCallId) return true
+        if (visited.has(child.callId)) continue
+        visited.add(child.callId)
+        pending.push(child.callId)
+      }
+    }
+    return false
+  }
 }
diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts
index 083bdc3566..37a1060fec 100644
--- a/packages/client/runtime/tests/history-fold.spec.ts
+++ b/packages/client/runtime/tests/history-fold.spec.ts
@@ -168,6 +168,37 @@ describe('projectConversationHistory', () => {
     })
   })
 
+  it('projects nested dispatches onto settled and interrupted history calls', () => {
+    const projection = projectConversationHistory([
+      ev.turnStart(0, 1),
+      ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
+      ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
+      ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
+      ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
+      ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
+      ev.toolResult(6, 1, 'settled', 'done'),
+      ev.turnEnd(7, 1),
+      ev.turnStart(8, 2),
+      ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
+      ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
+      ev.turnEnd(11, 2, 'aborted'),
+    ].map(event => ({ event })))
+
+    const settled = {
+      callId: 'settled',
+      subCalls: [{
+        callId: 'settled:code:1',
+        subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
+      }],
+    }
+    expect(projection.eventNodes).toMatchObject([settled])
+    expect(projection.contexts[0]?.nodes).toMatchObject([settled])
+    expect(projection.interruptedNodes).toMatchObject([{
+      callId: 'interrupted',
+      subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
+    }])
+  })
+
   it('drops completed token payloads without changing inspection projections', () => {
     const events = [
       ev.user(0, 'before'),
diff --git a/packages/client/runtime/tests/tool-call-tree.spec.ts b/packages/client/runtime/tests/tool-call-tree.spec.ts
new file mode 100644
index 0000000000..56e7f96694
--- /dev/null
+++ b/packages/client/runtime/tests/tool-call-tree.spec.ts
@@ -0,0 +1,65 @@
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { describe, expect, it } from 'vitest'
+import type { RunningToolCall } from '../src/client/sessions/conversation.ts'
+import { ToolCallTree } from '../src/client/sessions/tool-call-tree.ts'
+
+const at = (seq: number, type: string, data: Record): SessionEvent =>
+  ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
+
+const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
+  at(seq, 'tool/code-dispatch-start', {
+    parentCallId, subCallId, name: 'run_code', arguments: {},
+  })
+
+const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
+  at(seq, 'tool/code-dispatch', {
+    parentCallId, subCallId, name: 'run_code', arguments: {},
+    isError: false, content: [],
+  })
+
+const root = (callId: string): RunningToolCall => ({
+  callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
+  time: 1_700_000_000_000, callView: null, subCalls: [],
+})
+
+describe('ToolCallTree', () => {
+  it('rejects a self-parenting dispatch edge', () => {
+    const tree = new ToolCallTree()
+    const roots = [root('root')]
+
+    expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
+    expect(tree.projectRunningCalls(roots)).toBe(roots)
+  })
+
+  it('rejects a settling edge that would close a multi-call cycle', () => {
+    const tree = new ToolCallTree()
+    tree.apply(start(0, 'a', 'b'))
+    tree.apply(start(1, 'b', 'c'))
+
+    expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
+    expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
+      callId: 'a',
+      subCalls: [{
+        callId: 'b',
+        subCalls: [{ callId: 'c', subCalls: [] }],
+      }],
+    }])
+  })
+
+  it('accepts an acyclic graph with a shared descendant', () => {
+    const tree = new ToolCallTree()
+    tree.apply(start(0, 'a', 'b'))
+    tree.apply(start(1, 'a', 'c'))
+    tree.apply(start(2, 'b', 'd'))
+    tree.apply(start(3, 'c', 'd'))
+
+    expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
+    expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
+      callId: 'root',
+      subCalls: [{
+        callId: 'a',
+        subCalls: [{ callId: 'b' }, { callId: 'c' }],
+      }],
+    }])
+  })
+})
diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
index a89f724dd4..3afe471d50 100644
--- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
+++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
@@ -133,18 +133,18 @@ describe('render branch tails', () => {
     snap.runningCalls = [{
       callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
       time: 7_000, callView: null, subCalls: [{
-      kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
-      call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
-      callTime: 8_000,
-      content: [], isError: false, callView: null, resultView: null,
-      subCalls: [{
-        kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
-        call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
-        callTime: 8_500,
-        content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
-        subCalls: [],
+        kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
+        call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
+        callTime: 8_000,
+        content: [], isError: false, callView: null, resultView: null,
+        subCalls: [{
+          kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
+          call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
+          callTime: 8_500,
+          content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
+          subCalls: [],
+        }],
       }],
-    }],
     }]
     const chat = createChatStore().create()
     chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
index 5407a43717..97008e3e0c 100644
--- a/packages/client/ui-tool/README.i18n.yaml
+++ b/packages/client/ui-tool/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-tool/README.md
-README.md: bab3f92e4f6780b041cee2e682cb8ece3386ef15
-README.zh.md: 9187510fa354ba072835271d4b1153c8ee1105c0
+README.md: d6bc0f248cffaf4c65ecf6d97c7a4afb17fc8941
+README.zh.md: f4e8044fe6e10e1fbdfb70987efe07c884fa9ac8
diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
index bab3f92e4f..d6bc0f248c 100644
--- a/packages/client/ui-tool/README.md
+++ b/packages/client/ui-tool/README.md
@@ -44,5 +44,6 @@ None. The package is client-only presentation.
 
 ## Known Limitations and Deferred Work
 
+- The Host excludes `run_code` from Code Mode program bindings, so production events currently produce one dispatch level; the recursive Runtime/UI contract is ready for future nested producers.
 - Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot.
 - Tool copy temporarily reuses the `ui-conversation` locale namespace.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
index 9187510fa3..f4e8044fe6 100644
--- a/packages/client/ui-tool/README.zh.md
+++ b/packages/client/ui-tool/README.zh.md
@@ -44,5 +44,6 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 ## 已知限制与后续工作
 
+- Host 不把 `run_code` 暴露为 Code Mode 程序 binding,因此生产事件目前只能产生一层分发;递归的运行时/UI 契约已为未来的嵌套生产者做好准备。
 - 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。
 - Tool 文案暂时复用 `ui-conversation` locale namespace。
diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx
index 2bc97425e2..2f5efae420 100644
--- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx
+++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx
@@ -56,7 +56,9 @@ const runningCode = (callId: string): RunningToolCall => ({
   subCalls: [],
 })
 
-const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): ToolCallBlock => ({
+const subCall = (
+  seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false,
+): ToolCallBlock => ({
   kind: 'tool-result', seq, time: seq * 1_000,
   callId: `${parent}:code:${n}`,
   call: { name, argsRaw: JSON.stringify(args) },
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts
index 6853199aa1..4e110a5888 100644
--- a/packages/client/ui-trajectory/src/client/layout.ts
+++ b/packages/client/ui-trajectory/src/client/layout.ts
@@ -917,7 +917,6 @@ function expandSubCalls(
       absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
       toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
       callId: sub.callId,
-      subCalls: sub.subCalls,
       cell: {
         index: ++index,
         kind: 'subtool',

From 4de2de98e49517733a2fa57530ce2d73a8a649b5 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Sat, 8 Aug 2026 18:15:34 +0800
Subject: [PATCH 010/262] fix(client): bound nested Tool call depth

---
 packages/client/runtime/README.i18n.yaml      |  4 +--
 packages/client/runtime/README.md             |  2 +-
 packages/client/runtime/README.zh.md          |  2 +-
 .../src/client/sessions/tool-call-tree.ts     | 36 +++++++++++++++++--
 .../runtime/tests/tool-call-tree.spec.ts      | 28 +++++++++++++--
 5 files changed, 64 insertions(+), 8 deletions(-)

diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml
index bb64a12d4e..1edac07d5e 100644
--- a/packages/client/runtime/README.i18n.yaml
+++ b/packages/client/runtime/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/runtime/README.md
-README.md: fad0f6e4f948ebf412b2bd837a4f68e6dc805ec8
-README.zh.md: 4ede12a320082a5023ec121306650fce72a12663
+README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
+README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md
index fad0f6e4f9..a9b6049745 100644
--- a/packages/client/runtime/README.md
+++ b/packages/client/runtime/README.md
@@ -46,7 +46,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
 
 ## Code Mode child-call tree
 
-Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity.
+Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
 
 ## Session title projection
 
diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md
index 4ede12a320..6eee14bdbe 100644
--- a/packages/client/runtime/README.zh.md
+++ b/packages/client/runtime/README.zh.md
@@ -46,7 +46,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 
 ## Code Mode 子调用树
 
-每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。
+每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。
 
 ## Session 标题投影
 
diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/runtime/src/client/sessions/tool-call-tree.ts
index 574a10dd92..fbc32e0cef 100644
--- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts
+++ b/packages/client/runtime/src/client/sessions/tool-call-tree.ts
@@ -10,6 +10,9 @@ interface ProjectedBlock {
   value: ToolCallBlock
 }
 
+/** Fixed wire-safety ceiling for every recursive Tool call consumer. */
+export const MAX_TOOL_CALL_TREE_DEPTH = 256
+
 function sameReferences(
   left: readonly T[],
   right: readonly T[],
@@ -24,6 +27,7 @@ function sameReferences(
  */
 export class ToolCallTree {
   private readonly childrenByParent = new Map()
+  private readonly depthByCall = new Map()
   private readonly projectedByCall = new Map()
   private revision = 0
   private nodesCache: {
@@ -40,6 +44,7 @@ export class ToolCallTree {
   /** Forget all event-derived child calls before replaying a new window. */
   reset(): void {
     this.childrenByParent.clear()
+    this.depthByCall.clear()
     this.projectedByCall.clear()
     this.revision++
   }
@@ -68,7 +73,7 @@ export class ToolCallTree {
         subCalls: [],
       }
       const siblings = this.childrenByParent.get(data.parentCallId) ?? []
-      if (this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
+      if (!this.acceptEdge(data.parentCallId, data.subCallId)) return true
       this.childrenByParent.set(data.parentCallId, [...siblings, running])
       this.revision++
       return true
@@ -84,7 +89,7 @@ export class ToolCallTree {
     }
     const siblings = this.childrenByParent.get(data.parentCallId) ?? []
     const at = siblings.findIndex(sub => sub.callId === data.subCallId)
-    if (at === -1 && this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
+    if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true
     const started = at === -1 ? undefined : siblings[at]
     const settled: ToolResultNode = {
       kind: 'tool-result',
@@ -163,6 +168,33 @@ export class ToolCallTree {
     return value
   }
 
+  /**
+   * Accept an edge only when every recursive consumer can traverse it safely.
+   * Host-minted ids exclude cycles and current bindings emit one level; a
+   * malformed wire/history edge is consumed without hiding the rest of the session.
+   */
+  private acceptEdge(parentCallId: string, subCallId: string): boolean {
+    if (this.wouldCreateCycle(parentCallId, subCallId)) return false
+    const pending = [{
+      callId: subCallId,
+      depth: (this.depthByCall.get(parentCallId) ?? 1) + 1,
+    }]
+    const updates = new Map()
+    for (const candidate of pending) {
+      const knownDepth = updates.get(candidate.callId)
+        ?? this.depthByCall.get(candidate.callId)
+        ?? 1
+      if (candidate.depth <= knownDepth) continue
+      if (candidate.depth > MAX_TOOL_CALL_TREE_DEPTH) return false
+      updates.set(candidate.callId, candidate.depth)
+      for (const child of this.childrenByParent.get(candidate.callId) ?? []) {
+        pending.push({ callId: child.callId, depth: candidate.depth + 1 })
+      }
+    }
+    for (const [callId, depth] of updates) this.depthByCall.set(callId, depth)
+    return true
+  }
+
   private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
     if (parentCallId === subCallId) return true
     const pending = [subCallId]
diff --git a/packages/client/runtime/tests/tool-call-tree.spec.ts b/packages/client/runtime/tests/tool-call-tree.spec.ts
index 56e7f96694..541e6b2e19 100644
--- a/packages/client/runtime/tests/tool-call-tree.spec.ts
+++ b/packages/client/runtime/tests/tool-call-tree.spec.ts
@@ -1,7 +1,9 @@
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import { describe, expect, it } from 'vitest'
-import type { RunningToolCall } from '../src/client/sessions/conversation.ts'
-import { ToolCallTree } from '../src/client/sessions/tool-call-tree.ts'
+import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
+import {
+  MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
+} from '../src/client/sessions/tool-call-tree.ts'
 
 const at = (seq: number, type: string, data: Record): SessionEvent =>
   ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
@@ -62,4 +64,26 @@ describe('ToolCallTree', () => {
       }],
     }])
   })
+
+  it('rejects an edge beyond the recursive depth safety limit', () => {
+    const tree = new ToolCallTree()
+    for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
+      tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
+    }
+
+    expect(tree.apply(start(
+      MAX_TOOL_CALL_TREE_DEPTH,
+      `call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
+      `call-${MAX_TOOL_CALL_TREE_DEPTH}`,
+    ))).toBe(true)
+
+    let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
+    let depth = 1
+    while (current.subCalls.length > 0) {
+      current = current.subCalls[0]!
+      depth++
+    }
+    expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
+    expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
+  })
 })

From 9b169f7555433ee762b39ab385072265e284aa64 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 18:37:32 +0800
Subject: [PATCH 011/262] ci: run required checks on native Windows

---
 .agents/notes/archived/manifest.json          |   3 +
 ...27-wine-windows-gates-experiment.i18n.yaml |   4 +-
 ...026-07-27-wine-windows-gates-experiment.md |   1 +
 ...-07-27-wine-windows-gates-experiment.zh.md |   1 +
 ...rial-cross-platform-ci-reference.i18n.yaml |   4 +-
 ...7-21-serial-cross-platform-ci-reference.md |   2 +-
 ...1-serial-cross-platform-ci-reference.zh.md |   2 +-
 ...ortable-required-pull-request-ci.i18n.yaml |   4 +-
 ...07-23-portable-required-pull-request-ci.md |   6 +-
 ...23-portable-required-pull-request-ci.zh.md |   6 +-
 ...n-setup-for-symmetric-ci-caching.i18n.yaml |   4 +-
 ...m-action-setup-for-symmetric-ci-caching.md |   4 +-
 ...ction-setup-for-symmetric-ci-caching.zh.md |   4 +-
 ...8-native-windows-pull-request-ci.i18n.yaml |   6 +
 ...26-08-08-native-windows-pull-request-ci.md |  33 +++
 ...08-08-native-windows-pull-request-ci.zh.md |  33 +++
 .github/AGENTS.md                             |   2 +-
 .github/workflows/ci.yml                      | 117 ++-------
 AGENTS.md                                     |   1 -
 package.json                                  |   1 -
 scripts/ci-workflow.spec.ts                   |  22 ++
 scripts/wine-windows-gates.sh                 | 247 ------------------
 22 files changed, 144 insertions(+), 363 deletions(-)
 rename .agents/notes/{implemented => archived}/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml (67%)
 rename .agents/notes/{implemented => archived}/process/2026-07-27-wine-windows-gates-experiment.md (99%)
 rename .agents/notes/{implemented => archived}/process/2026-07-27-wine-windows-gates-experiment.zh.md (99%)
 create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
 create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
 create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
 delete mode 100755 scripts/wine-windows-gates.sh

diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index 1adde54c7b..638d87373d 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.agents/notes/archived/manifest.json
@@ -298,6 +298,9 @@
     "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e",
     "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3",
     "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe",
+    "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5",
+    "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936",
+    "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e",
     "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
     "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
     "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",
diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
similarity index 67%
rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
index 19945a6580..f7995fd9cf 100644
--- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
+++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.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/process/2026-07-27-wine-windows-gates-experiment.md
-2026-07-27-wine-windows-gates-experiment.md: 1b01fe00dc1588482442a3cedc35eefa7fdb0975
-2026-07-27-wine-windows-gates-experiment.zh.md: b50739f5b63c5836248c94927037e38900abc84f
+2026-07-27-wine-windows-gates-experiment.md: ce2ad1ad4506f59083de5731677d052161849e3d
+2026-07-27-wine-windows-gates-experiment.zh.md: a0a5e8e7cce078adf2c961fe4d8cc50edc28355c
diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md
similarity index 99%
rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md
index 1b01fe00dc..ce2ad1ad45 100644
--- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
+++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.md
@@ -1,6 +1,7 @@
 # Agent Note: Wine-run Windows blocking gates on Linux runners
 
 Status: implemented
+Archived: 2026-08-08
 
 English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md)
 
diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md
similarity index 99%
rename from .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
rename to .agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md
index b50739f5b6..a0a5e8e7cc 100644
--- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
+++ b/.agents/notes/archived/process/2026-07-27-wine-windows-gates-experiment.zh.md
@@ -1,6 +1,7 @@
 # Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁
 
 Status: implemented
+Archived: 2026-08-08
 
 [English](2026-07-27-wine-windows-gates-experiment.md) | 中文
 
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
index a1c49fdcae..dcb6e18e88 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md
-2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93
-2026-07-21-serial-cross-platform-ci-reference.zh.md: 4f5920ffc150ce16d5567ca05128f7524c2a2b21
+2026-07-21-serial-cross-platform-ci-reference.md: dfd762ad4d4eec61bc3a17d96865ccc4ac936ab3
+2026-07-21-serial-cross-platform-ci-reference.zh.md: 580340db2fbfc14458a4bbfbfa157db75e74e027
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
index 71b364f72a..dfd762ad4d 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
@@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel
 
 Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
 
-The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
+The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job also runs on standard `windows-2025` under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
index 4f5920ffc1..580340db2f 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
@@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
 
 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
 
-可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
+可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.md),拉取请求必需的 Windows 作业也在标准 `windows-2025` 上运行;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
 
 ## 曾考虑的替代方案
 
diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
index 440691d218..768e4298c7 100644
--- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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/process/2026-07-23-portable-required-pull-request-ci.md
-2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45
-2026-07-23-portable-required-pull-request-ci.zh.md: f7a95ced2353ca7aaee9a586cc4f0bb297cbf7d4
+2026-07-23-portable-required-pull-request-ci.md: 11de2b1dcbbdbdd7f9423131cd84c2940ac9218d
+2026-07-23-portable-required-pull-request-ci.zh.md: 02f5d758e1259d6a8e7cba70e439775bd1df2b28
diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
index 1a6939e838..11de2b1dcb 100644
--- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
+++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
@@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
+[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` under native Windows for the blocking surfaces and broader observational inventory ([native Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
 
-The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
+The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / native complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
 
 The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
 
@@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md)
 
 ## Consequences
 
-Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
+Ordinary pull requests spend enterprise capacity on the Linux critical path while the native Windows job uses standard Windows allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
 
 Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.
diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
index f7a95ced23..02f5d758e1 100644
--- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
+++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
@@ -12,9 +12,9 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断性检查范围([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单位于 master 串行参考流程中。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
+[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上以原生 Windows 环境运行,覆盖阻断性检查范围和更广泛的观测性清单([原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
 
-两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
+两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / native complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
 
 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
 
@@ -30,6 +30,6 @@ Status: implemented
 
 ## 后果
 
-普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
+普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而原生 Windows 作业使用标准 Windows 运行器容量。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
 
 企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
index 7005f08ce0..224544c058 100644
--- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2258a1fe5f91b27b8a3b636c7f3ad26f502a98df
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 6fba2ce3b508d9d6fc879460a63c0e12673bd866
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d9da8a4abe13772f3cc282392c03cdea54726c01
diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
index 006360cedf..6fba2ce3b5 100644
--- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
+++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
@@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i
 `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes:
 
 - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms.
-- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
-- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive.
+- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
+- **Cache-less or persistent** (no store-cache action): the required native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive.
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
index 2258a1fe5f..d9da8a4abe 100644
--- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
@@ -13,8 +13,8 @@ Status: implemented
 `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态:
 
 - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。
-- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。
-- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。
+- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。
+- **无缓存或持久化**(不使用 store 缓存 action):必需的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。
 
 ## 曾考虑的替代方案
 
diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
new file mode 100644
index 0000000000..3b3d2dc1dd
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md
+2026-08-08-native-windows-pull-request-ci.md: 926eec04cff63aaf25d1e85b3dcbc41464f25660
+2026-08-08-native-windows-pull-request-ci.zh.md: 8728f0eb08adcb1501f036bb19ff523ded406ccc
diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
new file mode 100644
index 0000000000..926eec04cf
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
@@ -0,0 +1,33 @@
+# Agent Note: Native Windows pull-request CI
+
+Status: implemented
+
+English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md)
+
+## Problem
+
+The required pull-request Windows verdict must protect behavior that depends on the operating system, not only toolchain branches selected by `process.platform`. The Wine lane executed Windows Node and PE binaries over a Linux kernel and case-sensitive ext4, required a hoisted dependency layout and host-created symlinks, and omitted NTFS, DACL, ConPTY, crash-durability, and the broader observational Windows inventory. With the native serial references disabled, ordinary CI had no real Windows-kernel signal.
+
+## Decision
+
+The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) runs on GitHub's standard `windows-2025` image under native PowerShell. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete`. The stable `windows` job id remains a dependency of `all checks passed`; its display name is `windows node 24 / native complete`.
+
+The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract.
+
+Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path.
+
+## Alternatives considered
+
+**Keep Wine on the required path.** Its warm wall clock was close to Linux CI and it selected win32 toolchain branches, but the compatibility-specific layout and kernel gaps could report green while supported native behavior was broken. Latency no longer outweighs that missing signal.
+
+**Restore the pre-Wine workflow verbatim.** The old definition captured the right runner boundary but also carried then-current provisioning and topology assumptions. Reconstructing the native job against the current actions, pnpm setup, gate graph, and aggregate dependency avoids reviving obsolete machinery.
+
+**Run native Windows only after merge.** A post-merge reference diagnoses portability regressions after they enter `master`; it cannot protect a pull request while those references are disabled or delayed.
+
+**Use an organization-owned larger Windows runner.** Larger images can reduce wall clock, but a required correctness path would then depend on repository-external labels and allocation. Standard `windows-2025` is the portable recovery boundary; larger runners remain benchmark targets.
+
+## Consequences
+
+Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation.
+
+Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane.
diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
new file mode 100644
index 0000000000..8728f0eb08
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 原生 Windows 拉取请求 CI
+
+Status: implemented
+
+[English](2026-08-08-native-windows-pull-request-ci.md) | 中文
+
+## 问题
+
+拉取请求必需的 Windows 判定必须为依赖操作系统的行为提供保障,而不能只覆盖由 `process.platform` 选择的工具链分支。Wine 通道在 Linux 内核与区分大小写的 ext4 之上执行 Windows Node 和 PE 二进制文件,要求采用 hoisted 依赖布局和由宿主侧创建的符号链接,也没有覆盖 NTFS、DACL、ConPTY、崩溃持久性与更广泛的观测性 Windows 清单。原生串行参考流程停用期间,常规 CI 没有任何真实的 Windows 内核信号。
+
+## 决策
+
+[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业在 GitHub 标准 `windows-2025` 镜像上使用原生 PowerShell 运行。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并运行 `pnpm run check:ci:windows-complete`。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项;其显示名称为 `windows node 24 / native complete`。
+
+聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。
+
+受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。
+
+## 曾考虑的替代方案
+
+**在必需路径上保留 Wine。** 其热运行墙钟时间接近 Linux CI,也会选择 win32 工具链分支;但兼容性专属布局和内核缺口可能在受支持的原生行为已经损坏时仍报告绿灯。这项延迟收益不足以抵消该信号缺失的代价。
+
+**逐字恢复 Wine 之前的工作流。** 旧定义确实捕获了正确的运行器边界,但也携带了当时的供应方式与拓扑假设。按照当前 action、pnpm 设置、门禁图和聚合依赖关系重新构建原生作业,可以避免重新引入过时机制。
+
+**只在合并后运行原生 Windows。** 合并后的参考流程只能在可移植性回归进入 `master` 后进行诊断;当这些参考流程被停用或延迟时,无法在拉取请求阶段提供保护。
+
+**使用组织自有的大型 Windows 运行器。** 更大规格的运行器镜像可以缩短墙钟时间,但必需的正确性路径将因此依赖仓库外部的运行器标签与分配能力。标准 `windows-2025` 是可移植恢复边界;大型运行器仍作为基准测试目标。
+
+## 后果
+
+在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。
+
+移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。
diff --git a/.github/AGENTS.md b/.github/AGENTS.md
index ff4fd4e6b2..c95b85e8e2 100644
--- a/.github/AGENTS.md
+++ b/.github/AGENTS.md
@@ -1,3 +1,3 @@
 # AGENTS.md — GitHub Actions
 
-Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md).
+Run jobs on Windows runners (`windows-*` labels) under native `pwsh`.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 46ab8e892f..0e96d50e50 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -300,27 +300,29 @@ jobs:
       - name: Run complete keyless Python suite
         run: uv run --python 3.10 --group test --project python/sdk pytest
 
-  # The required pull-request Windows signal: the two blocking win32 surfaces
-  # (workspace build, production site) execute with real, checksum-verified
-  # Windows Node under Wine on standard hosted Linux. The master
-  # serial-windows job below normally keeps the complete native-kernel
-  # inventory — including the observational portability gates this lane does
-  # not run —
-  # on real windows-2025. This job only provisions runner state (caches,
-  # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same
-  # script the optional local gate `pnpm run check:windows-wine` runs.
-  # Layout, fidelity limits, and measured timings live in
-  # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
+  # One standard Windows box shares setup across the required build/site checks
+  # and the observational portability inventory. Serial worker bounds keep this
+  # recovery path portable; Linux owns duplicate lint, coverage, and snapshots.
+  # See the native-Windows pull-request CI decision:
+  # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
   windows:
     if: github.event_name == 'pull_request'
-    runs-on: ubuntu-latest
-    name: windows node 24 / wine blocking
-    timeout-minutes: 15
+    runs-on: windows-2025
+    name: windows node 24 / native complete
+    env:
+      DSH_GATE_CONCURRENCY: '1'
+      DSH_PUBLINT_CONCURRENCY: '1'
     steps:
       - uses: actions/checkout@v6
         with:
           persist-credentials: false
 
+      - name: Enable Developer Mode (symlink support)
+        shell: pwsh
+        run: >-
+          reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
+          /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm
@@ -329,86 +331,15 @@ jobs:
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Configure pnpm store path
-        id: pnpm-store
-        run: |
-          store_root="$HOME/.local/share/pnpm/store"
-          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
-          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
-          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+      # Extracting the many-file pnpm store cache is slower than a clean install,
+      # and saving it adds more latency after the gates.
+      - name: Install (immutable)
+        shell: pwsh
+        run: pnpm install --frozen-lockfile
 
-      - uses: actions/cache/restore@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
-
-      # Master's wine-apt-cache job seeds the default-branch scope every pull
-      # request can read; a save from this job only reaches reruns of the
-      # same merge ref.
-      - name: Compose Wine apt cache key
-        id: wine-cache-key
-        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ~/wine-debs
-          key: ${{ steps.wine-cache-key.outputs.key }}
-
-      # Runner provisioning only — a developer machine installs Wine through
-      # its own package manager; the gate script assumes a wine binary and
-      # fails loud without one. Wine from the apt cache when present; else
-      # download the full dependency closure once and keep it for the next
-      # run. The `wine` dispatcher package (not bare `wine64`) is what puts a
-      # binary on PATH.
-      - name: Install Wine
-        run: |
-          if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
-            sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
-          else
-            sudo apt-get update
-            sudo apt-get install -y --no-install-recommends --download-only wine
-            mkdir -p "$HOME/wine-debs"
-            cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
-            sudo apt-get install -y --no-install-recommends wine
-          fi
-
-      - name: Run the Wine Windows gates
-        run: bash scripts/wine-windows-gates.sh
-
-      - name: Shut down wineserver
-        if: always()
-        run: wineserver -k 2>/dev/null || true
-
-  # Master seeds the Wine apt-archive cache in the default-branch scope,
-  # which every pull request's windows job can restore; saves from
-  # pull-request runs are scoped to their own merge ref and help nobody
-  # else. Runs in seconds when the image version already has a cache.
-  wine-apt-cache:
-    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
-    name: wine apt cache
-    runs-on: ubuntu-latest
-    timeout-minutes: 10
-    steps:
-      - name: Compose Wine apt cache key
-        id: wine-cache-key
-        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        id: wine-cache
-        with:
-          path: ~/wine-debs
-          key: ${{ steps.wine-cache-key.outputs.key }}
-
-      - name: Download the Wine dependency closure
-        if: steps.wine-cache.outputs.cache-hit != 'true'
-        run: |
-          sudo apt-get update
-          sudo apt-get install -y --no-install-recommends --download-only wine
-          mkdir -p "$HOME/wine-debs"
-          cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/"
-          du -sh "$HOME/wine-debs"
+      - name: Run blocking and observational Windows gates concurrently
+        shell: pwsh
+        run: pnpm run check:ci:windows-complete
 
   # The hosted reference jobs below are temporarily disabled; the self-hosted
   # standby remains active. Each enabled host executes the complete, unsharded
diff --git a/AGENTS.md b/AGENTS.md
index 64f2e944a8..575538bca7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,7 +65,6 @@ pnpm run typecheck
 pnpm run lint
 pnpm run duplication    # cross-file TypeScript clone detection
 pnpm run build          # tsc emits lib/types, tsdown bundles runtime
-pnpm run check:windows-wine  # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal
 pnpm run hygiene        # knip + publint + workspace constraints + NodeNext consumer check
 pnpm run doc-sync       # all documentation gates; leaf list in scripts/run-gates.ts
 pnpm run website:build  # VitePress build (doubles as dead-link check)
diff --git a/package.json b/package.json
index 423cbf813c..193e83dc1c 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,6 @@
     "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
     "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
     "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
-    "check:windows-wine": "bash scripts/wine-windows-gates.sh",
     "check:node-compat": "tsx scripts/run-gates.ts node-compat",
     "knip": "knip --treat-config-hints-as-errors",
     "publint": "tsx scripts/publint-all.ts",
diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts
index baeac7a8c0..b5d161dae7 100644
--- a/scripts/ci-workflow.spec.ts
+++ b/scripts/ci-workflow.spec.ts
@@ -26,6 +26,28 @@ describe('CI workflow', () => {
       })
     }
   })
+
+  it('runs the required Windows contract on a native hosted runner', () => {
+    const workflow = loadWorkflow('.github/workflows/ci.yml')
+    if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows)) {
+      throw new TypeError('CI workflow must define the windows job')
+    }
+
+    const windows = workflow.jobs.windows
+    if (!Array.isArray(windows.steps)) throw new TypeError('windows job must define steps')
+    const steps: unknown[] = windows.steps
+    const commandSteps = steps.filter((step): step is Record & { run: string } => (
+      isRecord(step) && typeof step.run === 'string'
+    ))
+
+    expect(windows['runs-on']).toBe('windows-2025')
+    expect(windows.name).toBe('windows node 24 / native complete')
+    expect(commandSteps).toHaveLength(3)
+    expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true)
+    expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')
+    expect(JSON.stringify(windows)).not.toMatch(/wine/i)
+    expect(workflow.jobs).not.toHaveProperty('wine-apt-cache')
+  })
 })
 
 describe('Issue lifecycle workflow', () => {
diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh
deleted file mode 100755
index b6d8ffffb0..0000000000
--- a/scripts/wine-windows-gates.sh
+++ /dev/null
@@ -1,247 +0,0 @@
-#!/usr/bin/env bash
-# Run the blocking Windows gates (workspace build, production site) with real
-# win-x64 Node.js under Wine — the same script the pull-request `windows` job
-# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
-# wraps. Owning rationale, fidelity limits, and measured timings:
-# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
-#
-# The working tree is never mutated: tracked plus untracked-unignored files
-# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
-# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
-# pnpm-workspace.yaml, and the install and gates run there against the shared
-# pnpm store. The Wine prefix and the checksum-verified Windows Node zip
-# persist in .cache/wine-windows/ so reruns skip provisioning.
-#
-# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
-# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
-# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
-
-set -euo pipefail
-
-repo_root="$(git rev-parse --show-toplevel)"
-node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
-cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
-
-export WINEDEBUG='-all'
-export WINEARCH=win64
-# Skip Wine Mono / Gecko installers: Node needs neither.
-export WINEDLLOVERRIDES='mscoree,mshtml='
-export WINEPREFIX="$cache_dir/prefix"
-
-# ---- preflight: fail loud before any expensive work --------------------
-wine_bin=''
-for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
-  if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi
-done
-# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both
-# accept the same "  " --check input.
-checksum_tool=''
-if command -v sha256sum > /dev/null; then
-  checksum_tool='sha256sum'
-elif command -v shasum > /dev/null; then
-  checksum_tool='shasum'
-fi
-missing=()
-[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
-command -v curl > /dev/null || missing+=('curl')
-command -v unzip > /dev/null || missing+=('unzip')
-[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)')
-if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
-command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
-if (( ${#missing[@]} > 0 )); then
-  printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
-  exit 1
-fi
-
-# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found.
-verify_sha256() {
-  case "$checksum_tool" in
-    sha256sum) printf '%s  %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;;
-    shasum) printf '%s  %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;;
-  esac
-}
-
-scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
-cleanup() {
-  wineserver -k > /dev/null 2>&1 || true
-  if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
-    echo "wine-windows-gates: scratch tree kept at $scratch"
-  else
-    rm -rf "$scratch"
-  fi
-}
-trap cleanup EXIT
-mkdir -p "$cache_dir" "$scratch/logs"
-
-# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
-provision_node() {
-  # Latest release of the primary line, checksum-verified against the same
-  # dist directory. Offline runs fall back to the newest cached zip, loudly.
-  local version zip
-  version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
-    | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
-    || true)"
-  if [ -n "$version" ]; then
-    zip="$cache_dir/node-$version-win-x64.zip"
-    if [ ! -f "$zip" ]; then
-      curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
-      local expected
-      expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
-        | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
-      [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
-      verify_sha256 "$expected" "$zip.tmp"
-      mv "$zip.tmp" "$zip"
-    fi
-  else
-    zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
-    [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
-    echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
-  fi
-  unzip -q -o "$zip" -d "$scratch/node-win"
-  echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
-}
-
-boot_wine() {
-  "$wine_bin" wineboot --init > /dev/null 2>&1 || true
-  wineserver -w || true
-}
-
-snapshot_and_install() {
-  # Tracked + untracked-unignored files, minus agent-session litter; the
-  # existence filter drops paths staged as deleted. Then the Wine-specific
-  # install-time overrides go on the SNAPSHOT only: hoisted because Windows
-  # Node under Wine does not realpath pnpm's isolated-layout symlinks, and
-  # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
-  # Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
-  # --ignore-scripts skips host lifecycle scripts no gate loads.
-  git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
-    | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
-    | tar -C "$repo_root" --null --files-from=- -cf - \
-    | tar -C "$scratch/tree" -xf -
-  cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
-
-nodeLinker: hoisted
-supportedArchitectures:
-  os: [current, win32]
-  cpu: [current, x64]
-EOF
-  # The hoisted linker — used only by this lane — has an upstream rename
-  # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy
-  # (observed on the tree's nested esbuild versions) rename their _tmp_*
-  # directory onto a path another racer already claimed, and the loser
-  # exits ERR_PNPM_ENOENT although an identical re-install succeeds.
-  # Exactly that signature earns up to two retries on a clean tree — the
-  # snapshot contains no node_modules, so wiping them restores the
-  # pre-install state; any other failure, or the race still standing after
-  # the final attempt, fails loud with the log tail.
-  local attempt
-  for attempt in 1 2 3; do
-    (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
-      && return 0
-    grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break
-    (( attempt < 3 )) || break
-    echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2
-    find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} +
-  done
-  tail -40 "$scratch/logs/install.log" >&2
-  return 1
-}
-
-mkdir "$scratch/tree"
-start=$SECONDS
-provision_node & node_pid=$!
-boot_wine & wine_pid=$!
-snapshot_and_install & install_pid=$!
-# Wait for EVERY child before judging any: a bare `wait` under set -e would
-# exit on the first failure and let the EXIT trap delete $scratch while the
-# other children still run inside it. Named statuses also make the report
-# point at the root cause instead of a downstream symptom.
-node_status=0; wait "$node_pid" || node_status=$?
-wine_status=0; wait "$wine_pid" || wine_status=$?
-install_status=0; wait "$install_pid" || install_status=$?
-provision_failed=0
-report_provision() {
-  if (( $2 != 0 )); then
-    echo "wine-windows-gates: FAILED $1 (exit $2)" >&2
-    provision_failed=$2
-  fi
-}
-report_provision 'Windows Node provisioning' "$node_status"
-report_provision 'wineboot' "$wine_status"
-report_provision 'workspace snapshot + pnpm install' "$install_status"
-if (( provision_failed != 0 )); then exit "$provision_failed"; fi
-node_win="$(cat "$scratch/node-win-path")"
-echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
-
-# ---- resolve entrypoints, lay the vue link, smoke ------------------------
-# Node under Wine cannot attach stdio to pipes the caller owns (Socket open
-# EBADF at bootstrap), so every invocation routes stdio through a file.
-wine_node() {
-  local log="$1"
-  shift
-  local status=0
-  "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
-  return "$status"
-}
-
-cd "$scratch/tree"
-tsc_js='node_modules/typescript/bin/tsc'
-tsdown_js='node_modules/tsdown/dist/run.mjs'
-vitepress_js='node_modules/vitepress/bin/vitepress.js'
-[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
-for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
-  [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
-done
-# VitePress links vue into the site's node_modules at build time; Wine cannot
-# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
-if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
-  mkdir -p website/node_modules
-  ln -s ../../node_modules/vue website/node_modules/vue
-fi
-
-wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
-cat "$scratch/logs/smoke.log"
-grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
-
-# ---- the two blocking surfaces, concurrently ------------------------------
-# The build preserves the face order from package.json: compile and bundle the
-# Host face before compiling and bundling the Client face.
-# Both statuses are captured so one failure cannot hide the other's result.
-build_gate() {
-  wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $?
-  wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $?
-  wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $?
-  wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client
-}
-site_gate() {
-  cd website
-  wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
-}
-
-start=$SECONDS
-build_gate & build_pid=$!
-site_gate & site_pid=$!
-build_status=0
-wait "$build_pid" || build_status=$?
-site_status=0
-wait "$site_pid" || site_status=$?
-elapsed=$((SECONDS - start))
-
-report() {
-  local label="$1" status="$2"
-  shift 2
-  if (( status == 0 )); then
-    echo "wine-windows-gates: PASS $label (${elapsed}s window)"
-  else
-    echo "== FAILED $label (exit $status) ==" >&2
-    for log in "$@"; do tail -n 200 "$log" >&2 || true; done
-  fi
-}
-report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \
-  "$scratch/logs/host-tsc.log" \
-  "$scratch/logs/host-tsdown.log" \
-  "$scratch/logs/client-tsc.log" \
-  "$scratch/logs/client-tsdown.log"
-report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
-if (( build_status != 0 )); then exit "$build_status"; fi
-exit "$site_status"

From b0eb7f8f35e385ad32b0a8af6e407de8f859c65f Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 01:23:26 +0800
Subject: [PATCH 012/262] docs: define unified GitHub label taxonomy

---
 ...07-25-semantic-pr-label-taxonomy.i18n.yaml |  4 +-
 .../2026-07-25-semantic-pr-label-taxonomy.md  | 65 +++++++------------
 ...026-07-25-semantic-pr-label-taxonomy.zh.md | 65 +++++++------------
 AGENTS.md                                     |  2 +-
 4 files changed, 53 insertions(+), 83 deletions(-)

diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml
index 1aaa9f992b..a83236521c 100644
--- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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/process/2026-07-25-semantic-pr-label-taxonomy.md
-2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514
-2026-07-25-semantic-pr-label-taxonomy.zh.md: 978f11af9402f248679e2087ff7fb513321b69b9
+2026-07-25-semantic-pr-label-taxonomy.md: 6aef5ecbb80005d1fcd90b4ee673bbbebb92cba6
+2026-07-25-semantic-pr-label-taxonomy.zh.md: 0bd3fbcaeca37fa894e92ce5a4e78c4125056ac9
diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md
index 3217b405e9..6aef5ecbb8 100644
--- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md
+++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md
@@ -6,66 +6,51 @@ English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md)
 
 ## Problem
 
-Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs.
+Pull request labels answer two independent questions: what kind of change the work makes and which durable repository domains it materially affects. Mixing those dimensions or keeping synonymous labels makes queries ambiguous, while a closed area inventory forces new domains into inaccurate categories.
 
-The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all.
+Issues already have a native Type and a separate source taxonomy. Reusing pull request kind or source labels across both object types duplicates metadata and weakens the meaning of each label family.
 
 ## Decision
 
-Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension.
+Every open or merged pull request carries exactly one canonical `kind/*` label and at least one materially affected `area/*` label. Closed pull requests that were never merged retain migrated historical assignments but do not receive invented missing classification. Operational labels may coexist without satisfying either dimension.
 
 ### Kinds
 
+The kind set is closed and mutually exclusive:
+
 | Kind | Meaning |
 |---|---|
-| `feature` | Adds or intentionally changes behavior. |
-| `bug-fix` | Corrects incorrect behavior. |
-| `doc` | Makes documentation the dominant intent. |
-| `testing` | Changes tests or testing infrastructure without changing product behavior. |
-| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. |
+| `kind/feature` | Adds or intentionally changes behavior. |
+| `kind/bug-fix` | Corrects incorrect behavior. |
+| `kind/doc` | Makes documentation the dominant intent. |
+| `kind/testing` | Changes tests or testing infrastructure without changing product behavior. |
+| `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. |
+| `kind/dependency` | Updates dependencies without another dominant intent. |
 
-The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change.
+The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix.
 
-Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work.
+### Areas
 
-### Current areas
+Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory.
 
-The 46 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level.
+The area set is intentionally extensible. When no existing description honestly covers a durable and reusable repository domain, an agent is empowered to create a concise `area/` label without separate approval. The agent must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and must report the new label after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable.
 
-| Group | Areas |
-|---|---|
-| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` |
-| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` |
-| Capabilities | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` |
-| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` |
-| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` |
+Kinds are not extended this way. A new kind changes the mutually exclusive classification contract and requires an explicit taxonomy change with corresponding policy enforcement.
 
-`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract.
+### Issues and operational labels
 
-`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts.
+Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record Issue provenance and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata.
 
-Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators.
-
-### Extensibility
-
-The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together.
-
-The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area.
+Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set.
 
 ## Alternatives considered
 
-- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered.
-- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy.
-- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need.
-- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work.
-- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface.
-- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts.
-- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts.
+- **Unprefixed labels.** Rejected because a flat name does not identify whether it classifies intent, domain, source, priority, or automation, and synonymous plain and prefixed labels caused ambiguous queries.
+- **A fixed area allowlist in repository policy.** Rejected because durable repository domains evolve. The `area/*` namespace remains mechanically recognizable while live descriptions carry the extensible inventory.
+- **Kinds on Issues.** Rejected because native Issue Type already owns that classification; duplicating it as a label creates drift.
+- **Automatic areas from paths.** Rejected because areas describe semantic impact across package boundaries, while changed paths include incidental tests, documentation, and support files.
+- **Exactly one area per pull request.** Rejected because coherent changes can materially affect several independent contracts.
 
 ## Consequences
 
-- Reviewers and automation receive one stable intent signal plus a complete semantic scope.
-- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts.
-- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies.
-- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change.
-- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning.
+Reviewers and automation can query intent, semantic scope, provenance, priority, and operational triggers independently. Maintainers must read the change and live label descriptions rather than infer classification from title prefixes or paths, and taxonomy migrations carry an explicit historical backfill and verification cost.
diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md
index 978f11af94..0bd3fbcaec 100644
--- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md
+++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md
@@ -6,66 +6,51 @@ Status: implemented
 
 ## 问题
 
-PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。
+PR(Pull Request)标签回答两个相互独立的问题:工作带来哪一类变更,以及会对哪些持久的仓库领域产生实质影响。混用这两个维度或保留同义标签会使查询含义模糊,而封闭的领域清单会迫使新领域被归入不准确的类别。
 
-仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。
+Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对象上复用 PR 类型或来源标签会产生重复元数据,并削弱每个标签族的含义。
 
 ## 决策
 
-每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。
+每项开放或已合并的 PR 都带有恰好一个规范的 `kind/*` 标签,以及至少一个受到实质影响的 `area/*` 标签。未合并即关闭的 PR 保留经迁移的历史标签关系,但不会凭空补充缺失分类。管理用途的标签可以并存,但不能满足这两个维度中的任一个。
 
 ### 类型
 
+类型集合封闭且互斥:
+
 | 类型 | 含义 |
 |---|---|
-| `feature` | 新增行为或有意改变行为。 |
-| `bug-fix` | 修正错误行为。 |
-| `doc` | 以文档变更为主要意图。 |
-| `testing` | 修改测试或测试基础设施,但不改变产品行为。 |
-| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 |
+| `kind/feature` | 新增行为或有意改变行为。 |
+| `kind/bug-fix` | 修正错误行为。 |
+| `kind/doc` | 以文档变更为主要意图。 |
+| `kind/testing` | 修改测试或测试基础设施,但不改变产品行为。 |
+| `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 |
+| `kind/dependency` | 在没有其他主要意图时更新依赖。 |
 
-类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。
+类型记录主要意图。配套测试、文档、清理或依赖调整不会覆盖一项功能或缺陷修复的主要意图。
 
-领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。
+### 领域
 
-### 当前领域
+领域表示持久的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。一项 PR 修改不同契约时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项契约。当前分类清单以 GitHub 上实时的 `area/*` 名称和描述为准。
 
-当前的 46 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。
+领域集合有意保持可扩展。当现有描述都无法准确涵盖一个持久且可复用的仓库领域时,agent(智能体)有权无须另行批准,直接创建简洁的 `area/` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后报告。仅为避免新增一个确有必要的标签而复用不准确的领域,不可接受。
 
-| 分组 | 领域 |
-|---|---|
-| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` |
-| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` |
-| 能力 | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` |
-| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` |
-| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` |
+类型不以这种方式扩展。新增类型会改变互斥的分类契约,必须明确修改分类体系并同步调整策略强制校验。
 
-`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。
+### Issue 与管理用途标签
 
-`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。
+Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍为可选。`source/*` 标签记录 Issue 来源,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。
 
-标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包产物,而不是文档生成器。
-
-### 可扩展性
-
-领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。
-
-类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。
+迁移标签时,须先保留语义,再移除别名:先添加规范替代标签并验证对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。
 
 ## 曾考虑的替代方案
 
-- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。
-- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。
-- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。
-- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。
-- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。
-- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。
-- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。
+- **无前缀标签。** 不予采纳,因为扁平名称无法表明其表示的是意图、领域、来源、优先级还是自动化用途,而同义的无前缀和带前缀标签会使查询含义模糊。
+- **在仓库策略中使用固定的领域允许列表。** 不予采纳,因为持久的仓库领域会持续演变。`area/*` 命名空间仍可由机制识别,而实时描述负责承载可扩展的分类清单。
+- **在 Issue 上使用类型标签。** 不予采纳,因为原生 Issue Type 已负责这项分类;再用标签重复表示会产生漂移。
+- **从路径自动生成领域。** 不予采纳,因为领域描述跨越包边界的语义影响,而变更路径还会包含偶然涉及的测试、文档和支持文件。
+- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以对多个独立契约产生实质影响。
 
 ## 后果
 
-- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。
-- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。
-- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。
-- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。
-- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。
+评审人和自动化流程可以分别查询意图、语义范围、来源、优先级和工作流触发器。维护者必须阅读变更内容和实时标签描述,而不能根据标题前缀或路径推断分类;迁移分类体系还会产生明确的历史回填与验证成本。
diff --git a/AGENTS.md b/AGENTS.md
index 64f2e944a8..90b2ab4a67 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
 - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
 - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
-- **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible.
+- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([workflow](.agents/skills/dsh-labeling/SKILL.md)).
 - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
 - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
 

From a9abb62195f438c61b163fa2e9b8133b1523e4a9 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 01:23:36 +0800
Subject: [PATCH 013/262] chore: enforce unified GitHub labels

---
 .github/dependabot.yml                   |   6 +-
 .github/issue-management/policy.mjs      |  43 +++++++++-
 .github/issue-management/policy.test.mjs | 100 ++++++++++++++++++++---
 AGENTS.md                                |   2 +-
 4 files changed, 132 insertions(+), 19 deletions(-)

diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 524d7912e2..dd91db8bf1 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -13,7 +13,7 @@ updates:
     cooldown:
       default-days: 30
     labels:
-      - "cleanup"
+      - "kind/dependency"
       - "area/infra"
 
   - package-ecosystem: "uv"
@@ -25,7 +25,7 @@ updates:
     cooldown:
       default-days: 30
     labels:
-      - "cleanup"
+      - "kind/dependency"
       - "area/infra"
 
   - package-ecosystem: "github-actions"
@@ -37,5 +37,5 @@ updates:
     cooldown:
       default-days: 30
     labels:
-      - "cleanup"
+      - "kind/dependency"
       - "area/infra"
diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs
index 608291c4f8..18a9b03628 100644
--- a/.github/issue-management/policy.mjs
+++ b/.github/issue-management/policy.mjs
@@ -12,6 +12,27 @@ const AUDIT_MARKER = ''
 const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/
 const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
 const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
+const PR_KINDS = new Set([
+  'kind/feature',
+  'kind/bug-fix',
+  'kind/doc',
+  'kind/testing',
+  'kind/cleanup',
+  'kind/dependency',
+])
+const LEGACY_LABELS = new Set([
+  'kind/bug',
+  'kind/documentation',
+  'bug-fix',
+  'doc',
+  'cleanup',
+  'testing',
+  'dependencies',
+  'ci',
+  'cli',
+  'llm',
+  'web-search',
+])
 const TERMINAL_STATUSES = new Set(['Done', 'No action'])
 const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
 
@@ -224,8 +245,14 @@ export function retainIssueReferences(references, issues) {
 export function validateIssue(issue) {
   const errors = validateBody(issue)
   const status = issue.status
+  const invalidLabels = issue.labels.filter(
+    (label) => label.startsWith('kind/') || LEGACY_LABELS.has(label),
+  )
 
   if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文')
+  if (invalidLabels.length > 0) {
+    errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
+  }
   if (
     /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test(
       issue.title,
@@ -261,12 +288,24 @@ export function validateIssue(issue) {
 export function validatePullRequest(input) {
   if (!requiresPullRequestPolicy(input)) return []
   const errors = []
-  const kinds = input.labels.filter((label) => label.startsWith('kind/'))
+  const kinds = input.labels.filter((label) => PR_KINDS.has(label))
+  const unknownKinds = input.labels.filter(
+    (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label),
+  )
+  const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label))
+  const sourceLabels = input.labels.filter((label) => label.startsWith('source/'))
   const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
   const areas = input.labels.filter((label) => label.startsWith('area/'))
 
   if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue')
-  if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`)
+  if (kinds.length !== 1) {
+    errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`)
+  }
+  if (unknownKinds.length > 0) {
+    errors.push(`PR 含不支持的 kind/*:${unknownKinds.join(', ')}`)
+  }
+  if (legacyLabels.length > 0) errors.push(`PR 含旧版标签:${legacyLabels.join(', ')}`)
+  if (sourceLabels.length > 0) errors.push(`source/* 仅用于 Issue:${sourceLabels.join(', ')}`)
   if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`)
   if (areas.length === 0) errors.push('PR 必须至少有一个 area/*')
   for (const number of input.references.all) {
diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs
index 86750127a7..bcaf450afa 100644
--- a/.github/issue-management/policy.test.mjs
+++ b/.github/issue-management/policy.test.mjs
@@ -27,6 +27,25 @@ const legalIssue = {
   stateReason: null,
 }
 
+const canonicalKinds = [
+  'kind/feature',
+  'kind/bug-fix',
+  'kind/doc',
+  'kind/testing',
+  'kind/cleanup',
+  'kind/dependency',
+]
+
+const reviewedPull = (labels) => ({
+  isDraft: false,
+  authorType: 'User',
+  reviewRequestCount: 1,
+  reviewCount: 0,
+  labels,
+  references: { all: [2], resolving: [], related: [2] },
+  issues: new Map([[2, { priority: null }]]),
+})
+
 test('counts only text outside details', () => {
   assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { units: 4, @@ -92,6 +111,32 @@ test('rejects metadata prefixes in an Issue title', () => { assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) }) +test('reserves PR kind and legacy labels for pull requests', () => { + for (const label of [ + ...canonicalKinds, + 'kind/experimental', + 'kind/bug', + 'kind/documentation', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', + ]) { + assert.ok( + validateIssue({ ...legalIssue, labels: [label] }).some((error) => + error.startsWith('Issue 不得使用 PR kind 或旧版标签:'), + ), + label, + ) + } + assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), []) +}) + test('keeps terminal Status aligned with the native close reason', () => { assert.deepEqual( validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), @@ -260,22 +305,51 @@ test('requires repository PR labels in the enforcement scope', () => { references: { all: [2], resolving: [], related: [2] }, issues: new Map([[2, { priority: null }]]), }) - assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须恰好有一个允许的 kind/*,当前为 0')) assert.ok(errors.includes('PR 必须至少有一个 area/*')) }) -test('accepts repository-extensible kind labels', () => { - assert.deepEqual( - validatePullRequest({ - isDraft: false, - authorType: 'User', - reviewRequestCount: 1, - reviewCount: 0, - labels: ['kind/dependency', 'area/infra'], - references: { all: [2], resolving: [], related: [2] }, - issues: new Map([[2, { priority: null }]]), - }), - [], +test('accepts exactly the canonical kinds with extensible areas', () => { + for (const kind of canonicalKinds) { + assert.deepEqual(validatePullRequest(reviewedPull([kind, 'area/future-domain'])), [], kind) + } +}) + +test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => { + assert.ok( + validatePullRequest( + reviewedPull(['kind/feature', 'kind/doc', 'area/web']), + ).includes('PR 必须恰好有一个允许的 kind/*,当前为 2'), + ) + assert.ok( + validatePullRequest(reviewedPull(['kind/experimental', 'area/web'])).includes( + 'PR 含不支持的 kind/*:kind/experimental', + ), + ) + for (const label of [ + 'kind/bug', + 'kind/documentation', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', + ]) { + assert.ok( + validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) => + error.startsWith('PR 含旧版标签:'), + ), + label, + ) + } + assert.ok( + validatePullRequest( + reviewedPull(['kind/feature', 'area/web', 'source/internal-pr']), + ).includes('source/* 仅用于 Issue:source/internal-pr'), ) }) diff --git a/AGENTS.md b/AGENTS.md index 90b2ab4a67..9344e2e6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). -- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([workflow](.agents/skills/dsh-labeling/SKILL.md)). +- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 07ac5e6d4cbf06e5078e73844e38e97684788a75 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:00:10 +0800 Subject: [PATCH 014/262] fix(labels): address taxonomy review findings --- .../2026-07-25-semantic-pr-label-taxonomy.md | 56 --------------- ...026-07-25-semantic-pr-label-taxonomy.zh.md | 56 --------------- ...07-27-dependabot-version-updates.i18n.yaml | 4 +- .../2026-07-27-dependabot-version-updates.md | 2 +- ...026-07-27-dependabot-version-updates.zh.md | 2 +- ...8-unified-github-label-taxonomy.i18n.yaml} | 6 +- ...026-08-08-unified-github-label-taxonomy.md | 72 +++++++++++++++++++ ...-08-08-unified-github-label-taxonomy.zh.md | 72 +++++++++++++++++++ .github/issue-management/policy.mjs | 2 + .github/issue-management/policy.test.mjs | 42 +++++------ AGENTS.md | 2 +- 11 files changed, 172 insertions(+), 144 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md delete mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md rename .agents/notes/implemented/process/{2026-07-25-semantic-pr-label-taxonomy.i18n.yaml => 2026-08-08-unified-github-label-taxonomy.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md create mode 100644 .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md deleted file mode 100644 index 6aef5ecbb8..0000000000 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ /dev/null @@ -1,56 +0,0 @@ -# Agent Note: Semantic pull request label taxonomy - -Status: implemented - -English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) - -## Problem - -Pull request labels answer two independent questions: what kind of change the work makes and which durable repository domains it materially affects. Mixing those dimensions or keeping synonymous labels makes queries ambiguous, while a closed area inventory forces new domains into inaccurate categories. - -Issues already have a native Type and a separate source taxonomy. Reusing pull request kind or source labels across both object types duplicates metadata and weakens the meaning of each label family. - -## Decision - -Every open or merged pull request carries exactly one canonical `kind/*` label and at least one materially affected `area/*` label. Closed pull requests that were never merged retain migrated historical assignments but do not receive invented missing classification. Operational labels may coexist without satisfying either dimension. - -### Kinds - -The kind set is closed and mutually exclusive: - -| Kind | Meaning | -|---|---| -| `kind/feature` | Adds or intentionally changes behavior. | -| `kind/bug-fix` | Corrects incorrect behavior. | -| `kind/doc` | Makes documentation the dominant intent. | -| `kind/testing` | Changes tests or testing infrastructure without changing product behavior. | -| `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | -| `kind/dependency` | Updates dependencies without another dominant intent. | - -The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. - -### Areas - -Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory. - -The area set is intentionally extensible. When no existing description honestly covers a durable and reusable repository domain, an agent is empowered to create a concise `area/` label without separate approval. The agent must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and must report the new label after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. - -Kinds are not extended this way. A new kind changes the mutually exclusive classification contract and requires an explicit taxonomy change with corresponding policy enforcement. - -### Issues and operational labels - -Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record Issue provenance and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. - -Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. - -## Alternatives considered - -- **Unprefixed labels.** Rejected because a flat name does not identify whether it classifies intent, domain, source, priority, or automation, and synonymous plain and prefixed labels caused ambiguous queries. -- **A fixed area allowlist in repository policy.** Rejected because durable repository domains evolve. The `area/*` namespace remains mechanically recognizable while live descriptions carry the extensible inventory. -- **Kinds on Issues.** Rejected because native Issue Type already owns that classification; duplicating it as a label creates drift. -- **Automatic areas from paths.** Rejected because areas describe semantic impact across package boundaries, while changed paths include incidental tests, documentation, and support files. -- **Exactly one area per pull request.** Rejected because coherent changes can materially affect several independent contracts. - -## Consequences - -Reviewers and automation can query intent, semantic scope, provenance, priority, and operational triggers independently. Maintainers must read the change and live label descriptions rather than infer classification from title prefixes or paths, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md deleted file mode 100644 index 0bd3fbcaec..0000000000 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ /dev/null @@ -1,56 +0,0 @@ -# Agent Note: 语义化 PR 标签分类体系 - -Status: implemented - -[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文 - -## 问题 - -PR(Pull Request)标签回答两个相互独立的问题:工作带来哪一类变更,以及会对哪些持久的仓库领域产生实质影响。混用这两个维度或保留同义标签会使查询含义模糊,而封闭的领域清单会迫使新领域被归入不准确的类别。 - -Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对象上复用 PR 类型或来源标签会产生重复元数据,并削弱每个标签族的含义。 - -## 决策 - -每项开放或已合并的 PR 都带有恰好一个规范的 `kind/*` 标签,以及至少一个受到实质影响的 `area/*` 标签。未合并即关闭的 PR 保留经迁移的历史标签关系,但不会凭空补充缺失分类。管理用途的标签可以并存,但不能满足这两个维度中的任一个。 - -### 类型 - -类型集合封闭且互斥: - -| 类型 | 含义 | -|---|---| -| `kind/feature` | 新增行为或有意改变行为。 | -| `kind/bug-fix` | 修正错误行为。 | -| `kind/doc` | 以文档变更为主要意图。 | -| `kind/testing` | 修改测试或测试基础设施,但不改变产品行为。 | -| `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | -| `kind/dependency` | 在没有其他主要意图时更新依赖。 | - -类型记录主要意图。配套测试、文档、清理或依赖调整不会覆盖一项功能或缺陷修复的主要意图。 - -### 领域 - -领域表示持久的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。一项 PR 修改不同契约时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项契约。当前分类清单以 GitHub 上实时的 `area/*` 名称和描述为准。 - -领域集合有意保持可扩展。当现有描述都无法准确涵盖一个持久且可复用的仓库领域时,agent(智能体)有权无须另行批准,直接创建简洁的 `area/` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后报告。仅为避免新增一个确有必要的标签而复用不准确的领域,不可接受。 - -类型不以这种方式扩展。新增类型会改变互斥的分类契约,必须明确修改分类体系并同步调整策略强制校验。 - -### Issue 与管理用途标签 - -Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍为可选。`source/*` 标签记录 Issue 来源,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 - -迁移标签时,须先保留语义,再移除别名:先添加规范替代标签并验证对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 - -## 曾考虑的替代方案 - -- **无前缀标签。** 不予采纳,因为扁平名称无法表明其表示的是意图、领域、来源、优先级还是自动化用途,而同义的无前缀和带前缀标签会使查询含义模糊。 -- **在仓库策略中使用固定的领域允许列表。** 不予采纳,因为持久的仓库领域会持续演变。`area/*` 命名空间仍可由机制识别,而实时描述负责承载可扩展的分类清单。 -- **在 Issue 上使用类型标签。** 不予采纳,因为原生 Issue Type 已负责这项分类;再用标签重复表示会产生漂移。 -- **从路径自动生成领域。** 不予采纳,因为领域描述跨越包边界的语义影响,而变更路径还会包含偶然涉及的测试、文档和支持文件。 -- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以对多个独立契约产生实质影响。 - -## 后果 - -评审人和自动化流程可以分别查询意图、语义范围、来源、优先级和工作流触发器。维护者必须阅读变更内容和实时标签描述,而不能根据标题前缀或路径推断分类;迁移分类体系还会产生明确的历史回填与验证成本。 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index 316c31771e..dea250de53 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.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/process/2026-07-27-dependabot-version-updates.md -2026-07-27-dependabot-version-updates.md: 5d42563788d9f1e72da65c8e9750d6b1ecba06a5 -2026-07-27-dependabot-version-updates.zh.md: 4847059944e7e35de5719a6cbfd3d5b133467ccb +2026-07-27-dependabot-version-updates.md: bba83c9e720cf87f91638131d7f9580422ea7f76 +2026-07-27-dependabot-version-updates.zh.md: 74b399778fdd06cbbe38234f7bddc5c28c4fd600 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md index 5d42563788..bba83c9e72 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -12,7 +12,7 @@ Maintained registry and GitHub Actions dependencies need a regular update path. The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, including `native/landlock-run`, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. The [in-repository Landlock release decision](2026-08-06-in-repository-landlock-release.md) owns the shared-workspace boundary. -The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `kind/dependency` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index 4847059944..74b399778f 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -12,7 +12,7 @@ Status: implemented 默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为包含 `native/landlock-run` 的根 pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。[仓库内 Landlock 发布决策](2026-08-06-in-repository-landlock-release.md)负责共享工作区边界。 -根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `kind/dependency` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml similarity index 56% rename from .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml rename to .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml index a83236521c..5ee57b914f 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml @@ -1,6 +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/process/2026-07-25-semantic-pr-label-taxonomy.md -2026-07-25-semantic-pr-label-taxonomy.md: 6aef5ecbb80005d1fcd90b4ee673bbbebb92cba6 -2026-07-25-semantic-pr-label-taxonomy.zh.md: 0bd3fbcaeca37fa894e92ce5a4e78c4125056ac9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md +2026-08-08-unified-github-label-taxonomy.md: fddda6c99053e55d3e31a2f6e5c55add09772117 +2026-08-08-unified-github-label-taxonomy.zh.md: c8d727c9a56d6c7c25f73eacde42e0bef1c779b7 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md new file mode 100644 index 0000000000..fddda6c990 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md @@ -0,0 +1,72 @@ +# Agent Note: Unified GitHub label taxonomy + +Status: implemented + +English | [中文](2026-08-08-unified-github-label-taxonomy.zh.md) + +## Problem + +Pull request labels answer two independent questions: what kind of change the work makes and which durable repository domains it materially affects. Mixing those dimensions or retaining synonymous plain and namespaced labels makes queries ambiguous, while a closed area inventory forces new domains into inaccurate categories. + +Issues already have a native Type and a separate source taxonomy. Reusing pull request kind or source labels across both object types duplicates metadata and weakens the meaning of each family. + +## Decision + +Every open or merged pull request carries exactly one canonical `kind/*` label and at least one materially affected `area/*` label. Closed pull requests that were never merged retain migrated historical assignments but do not receive invented missing classification. Operational labels may coexist without satisfying either dimension. + +### Kinds + +The kind set is closed and mutually exclusive: + +| Kind | Meaning | +|---|---| +| `kind/feature` | Adds or intentionally changes behavior. | +| `kind/bug-fix` | Corrects incorrect behavior. | +| `kind/doc` | Makes documentation the dominant intent. | +| `kind/testing` | Changes tests or testing infrastructure without changing product behavior. | +| `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | +| `kind/dependency` | Updates dependencies without another dominant intent. | + +The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. A new kind changes this classification contract and requires an explicit taxonomy and policy change. + +Repository policy rejects unsupported `kind/*` values and reserves every alias removed by the unification: `kind/bug`, `kind/documentation`, `feature`, `bug-fix`, `doc`, `cleanup`, `testing`, `dependencies`, `ci`, `cli`, `llm`, and `web-search`. Reserving the exact migrated set prevents an obsolete synonym from being recreated as an apparently unrelated operational label. + +### Areas + +Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory; this record owns the selection rule and the non-obvious boundaries that cannot fit reliably in short label descriptions. + +- `area/web` covers browser and Electron graphical interfaces, `area/vscode` covers the editor extension, and `area/api` covers cross-interface protocols and language SDKs. +- `area/planning` covers goals, plans, todos, and scheduling, while `area/workflow` covers executable workflows and background task runtimes. +- `area/artifact` deliberately combines artifacts, attachments, and multimodal delivery. Split labels become justified only when those concerns again need independent review or queries. +- `area/tools` applies to generic registry, schema, and execution contracts. A concrete capability uses its own area unless it also changes that generic contract. +- `area/hooks` means the Claude Code and Codex bridges, `area/infra` covers build, release, CI, repository gates, generators, dependencies, and developer tooling, and `area/windows` covers native Windows product support rather than CI runner selection. + +The area set is intentionally extensible. When no existing description honestly covers a durable and reusable domain, an agent may create a concise `area/` label without separate approval. It must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and it reports the new label and rationale to the requester after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. + +### Issues and migrations + +Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record Issue provenance and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. + +Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. + +## Alternatives considered + +**Unprefixed labels.** Plain names reduce visual noise, but they do not identify whether a label classifies intent, domain, source, priority, or automation. Retaining both plain and namespaced synonyms also makes queries and policy enforcement ambiguous. + +**One undifferentiated label set.** A label's presence would not prove that both intent and semantic scope were considered. + +**A fixed area allowlist in repository policy.** Durable repository domains evolve. The `area/*` namespace remains mechanically recognizable while live descriptions carry the extensible inventory. + +**Package- or path-derived areas.** Areas describe semantic impact across package boundaries, while changed paths include incidental tests, documentation, and support files. + +**Separate labels for every delivery shell or media lifecycle.** Browser and Electron delivery share one graphical domain, and artifact, attachment, and multimodal delivery currently share one review/query domain. A split belongs in a later taxonomy change only when it restores useful independent classification. + +**Broad implementation labels in place of semantic domains.** A concrete capability is not merely its tool, interface, filesystem, or process implementation. Generic implementation areas apply only when their own contracts change. + +**Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift. + +**Exactly one area per pull request.** Coherent changes can materially affect several independent contracts, and dropping secondary areas hides affected scope. + +## Consequences + +Reviewers and automation can query intent, semantic scope, provenance, priority, and operational triggers independently. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md new file mode 100644 index 0000000000..c8d727c9a5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md @@ -0,0 +1,72 @@ +# Agent Note: 统一 GitHub 标签分类体系 + +Status: implemented + +[English](2026-08-08-unified-github-label-taxonomy.md) | 中文 + +## 问题 + +PR(Pull Request)标签回答两个相互独立的问题:工作带来哪一类变更,以及会对哪些持久的仓库领域产生实质影响。混用这两个维度,或同时保留同义的无前缀标签与带命名空间的标签,都会使查询含义模糊;封闭的领域清单则会迫使新领域归入不准确的类别。 + +Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对象上复用 PR 类型或来源标签会产生重复元数据,并削弱每个标签族的含义。 + +## 决策 + +每项开放或已合并的 PR 都带有恰好一个规范的 `kind/*` 标签,以及至少一个表示实质受影响领域的 `area/*` 标签。未合并即关闭的 PR 保留经迁移的历史标签关系,但不会凭空补充缺失分类。管理用途的标签可以并存,但不能满足这两个维度中的任一个。 + +### 变更类型 + +类型集合封闭且互斥: + +| 变更类型 | 含义 | +|---|---| +| `kind/feature` | 新增行为或有意改变行为。 | +| `kind/bug-fix` | 修正错误行为。 | +| `kind/doc` | 以文档变更为主导意图。 | +| `kind/testing` | 在不改变产品行为的前提下修改测试或测试基础设施。 | +| `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | +| `kind/dependency` | 在没有其他主导意图时更新依赖。 | + +类型记录主导意图。配套测试、文档、清理或依赖调整不会盖过功能变更或缺陷修复这一主导意图。新增类型会改变这项分类契约,因此必须明确修改分类体系和政策。 + +仓库政策会拒绝不支持的 `kind/*` 值,并将统一过程中移除的所有别名列为保留名称:`kind/bug`、`kind/documentation`、`feature`、`bug-fix`、`doc`、`cleanup`、`testing`、`dependencies`、`ci`、`cli`、`llm` 和 `web-search`。精确保留这组已迁移的名称,可以防止过时的同义名称被重新创建成看似无关的管理用途标签。 + +### 领域 + +领域表示持久的语义领域,而不是临时专项、归属关系或偶然触及的每条路径。一项 PR 修改不同契约时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项契约。GitHub 上现行的 `area/*` 名称和说明定义当前清单;本记录定义选择规则,以及简短标签说明无法可靠容纳的非显然边界。 + +- `area/web` 覆盖浏览器与 Electron 图形界面,`area/vscode` 覆盖编辑器扩展,`area/api` 覆盖跨界面协议与各语言 SDK。 +- `area/planning` 覆盖目标、计划、待办和调度,`area/workflow` 则覆盖可执行工作流与后台任务运行时。 +- `area/artifact` 有意合并产物、附件与多模态交付。只有当这些关注点再次需要独立评审或查询时,才有理由拆分标签。 +- `area/tools` 适用于通用注册表、schema 与执行契约。具体能力使用自身的领域标签,除非它还修改了这项通用契约。 +- `area/hooks` 表示 Claude Code 与 Codex 桥接,`area/infra` 覆盖构建、发布、CI、仓库门禁、生成器、依赖与开发者工具,`area/windows` 覆盖原生 Windows 产品支持,而不是 CI runner 的选型。 + +领域集合有意保持可扩展。当现有说明都无法如实涵盖一个持久且可复用的领域时,agent(智能体)无需另行批准,即可创建一个简洁的 `area/` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后向请求者报告该标签及理由。仅为避免新增一个确有必要的领域标签而复用不准确的领域,不可接受。 + +### Issue 与迁移 + +Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然可选。`source/*` 标签记录 Issue 来源,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 + +迁移标签时,须先保留语义,再移除别名:先添加规范替代标签,核验可加标签对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 + +## 考虑过的替代方案 + +**无前缀标签。** 无前缀名称可以减少视觉噪声,但无法表明标签分类的是意图、领域、来源、优先级还是自动化用途。同时保留无前缀和带命名空间的同义标签,也会使查询和政策执行含义模糊。 + +**不区分维度的单一标签集合。** 某个标签存在,并不能证明意图和语义范围都经过了考虑。 + +**仓库政策中的固定领域允许清单。** 持久的仓库领域会演进。`area/*` 命名空间仍可机械识别,而现行说明承载可扩展清单。 + +**按包或路径派生的领域。** 领域描述跨越包边界的语义影响,而变更路径会包含偶然涉及的测试、文档和支持文件。 + +**为每种交付载体或媒体生命周期单设标签。** 浏览器与 Electron 交付共用一个图形界面领域,产物、附件与多模态交付目前也共用一个评审/查询领域。只有当拆分能恢复有用的独立分类时,才应在后续分类体系变更中进行。 + +**用宽泛的实现标签取代语义领域。** 一项具体能力并不只是其工具、接口、文件系统或进程实现。通用实现领域只在其自身契约变化时适用。 + +**在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。 + +**每个 PR 恰好一个领域。** 内聚的变更可能对多个独立契约产生实质影响,丢弃次要领域会隐藏受影响范围。 + +## 后果 + +评审人和自动化流程可以分别查询意图、语义范围、来源、优先级和工作流触发条件。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 18a9b03628..09e1bbe7fd 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -20,9 +20,11 @@ const PR_KINDS = new Set([ 'kind/cleanup', 'kind/dependency', ]) +// Aliases removed by the unified taxonomy migration remain reserved so they cannot be recreated. const LEGACY_LABELS = new Set([ 'kind/bug', 'kind/documentation', + 'feature', 'bug-fix', 'doc', 'cleanup', diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index bcaf450afa..8a9b0f91e6 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -36,6 +36,22 @@ const canonicalKinds = [ 'kind/dependency', ] +// Keep an independent oracle rather than importing the implementation's reserved set. +const legacyLabels = [ + 'kind/bug', + 'kind/documentation', + 'feature', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', +] + const reviewedPull = (labels) => ({ isDraft: false, authorType: 'User', @@ -115,17 +131,7 @@ test('reserves PR kind and legacy labels for pull requests', () => { for (const label of [ ...canonicalKinds, 'kind/experimental', - 'kind/bug', - 'kind/documentation', - 'bug-fix', - 'doc', - 'cleanup', - 'testing', - 'dependencies', - 'ci', - 'cli', - 'llm', - 'web-search', + ...legacyLabels, ]) { assert.ok( validateIssue({ ...legalIssue, labels: [label] }).some((error) => @@ -326,19 +332,7 @@ test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => { 'PR 含不支持的 kind/*:kind/experimental', ), ) - for (const label of [ - 'kind/bug', - 'kind/documentation', - 'bug-fix', - 'doc', - 'cleanup', - 'testing', - 'dependencies', - 'ci', - 'cli', - 'llm', - 'web-search', - ]) { + for (const label of legacyLabels) { assert.ok( validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) => error.startsWith('PR 含旧版标签:'), diff --git a/AGENTS.md b/AGENTS.md index 9344e2e6e2..928a118868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). -- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md)). +- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 702e2d024a0f6957c785b6f597a28ee53da458aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:29:43 +0800 Subject: [PATCH 015/262] fix(windows): canonicalize native watch paths --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 4 ++ ...08-08-native-windows-pull-request-ci.zh.md | 4 ++ .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 2 +- .../credentials-local/src/index.ts | 4 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 4 +- packages/ui/app-boot/tests/hmr-config.spec.ts | 46 +++++++++++++++++-- packages/util/paths/README.i18n.yaml | 4 +- packages/util/paths/README.md | 6 ++- packages/util/paths/README.zh.md | 6 ++- packages/util/paths/src/index.ts | 30 +++++++++++- packages/util/paths/tests/paths.spec.ts | 22 ++++++++- scripts/project-doc-site.spec.ts | 6 +-- vendor/README.md | 2 +- vendor/hmr/README.md | 6 +++ vendor/hmr/src/index.ts | 31 ++++++++----- 21 files changed, 157 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 3b3d2dc1dd..c9d06abacf 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 926eec04cff63aaf25d1e85b3dcbc41464f25660 -2026-08-08-native-windows-pull-request-ci.zh.md: 8728f0eb08adcb1501f036bb19ff523ded406ccc +2026-08-08-native-windows-pull-request-ci.md: 8b6766cfa8df48c6c61410c06a410340a0c9bb21 +2026-08-08-native-windows-pull-request-ci.zh.md: 29cb1f725f4212c13974d642388edaf1df16b759 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 926eec04cf..8b6766cfa8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,6 +14,8 @@ The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) run The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered @@ -30,4 +32,6 @@ Wine-only infrastructure is absent from the supported workflow: there is no apt- Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation. +The native lane is also a portability inventory: its exact-head acceptance requires every blocking gate to pass and the final summary to contain no non-blocking failure. That distinction caught path contracts which a successful wrapper conclusion alone would have concealed. + Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 8728f0eb08..29cb1f725f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,6 +14,8 @@ Status: implemented 聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 @@ -30,4 +32,6 @@ Status: implemented 在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。 +原生通道也是一份可移植性清单:其分支头精确验收要求每个阻断门禁都通过,且最终汇总中不包含任何非阻断失败。这一区分发现了路径契约问题;若只看包装层的成功结论,这些问题本会被掩盖。 + 移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 07a3efd5c3..66ad77f648 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 8e95a890a8e38172cf8984653a01c59570f0061a -README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337 +README.md: 035d558d8cbd51ce46af72d7bd9cd56bed85ebcd +README.zh.md: 11c772951dab3ce0cea3a13e739198d41028d598 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 8e95a890a8..035d558d8c 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -47,7 +47,7 @@ The provider creates the directory `0700` and creates or atomically replaces the ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 04ad07ae4e..11c772951d 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -47,7 +47,7 @@ OPENAI_API_KEY: sk-… ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 ## 安全边界 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index e781c10f3e..67b598d7e0 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -41,7 +41,7 @@ import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' @@ -270,7 +270,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- same watcher discipline as settings-local by design: the serialized-refresh and quiesce-on-dispose shape is the reviewed lifecycle contract, not accidental repetition. */ - const watcher = chokidarWatch(this.spec.filename, { + const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index ec26e4241f..188e4361bb 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/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/settings/settings-local/README.md -README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4 -README.zh.md: 03c7ef7c35312d9e1e4a86b0df71412875af7d6c +README.md: a0bc94630f78aa7c101e3eb0795a7d07e585e347 +README.zh.md: 2d2d0828f2eadde9867ab91fe8fbd4c61dea4c11 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index d1f3d755f9..a0bc94630f 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -24,6 +24,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. +- **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv. - **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. - **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 03c7ef7c35..2d2d0828f2 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -24,6 +24,7 @@ - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 +- **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。 - **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 - **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 0ce07af40c..16bc641f05 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' /** Plugin config: file location and hot-reload behavior. */ @@ -235,7 +235,7 @@ export class SettingsLocal extends Settings { // silently ignored or overwritten. yield* super[Service.init]() const watcher = this.spec.watch - ? chokidarWatch(this.spec.filename, { + ? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts index 1892a6e73a..fe6bb556b4 100644 --- a/packages/ui/app-boot/tests/hmr-config.spec.ts +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,12 +8,12 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { describe, expect, it } from 'vitest' -async function bootHmr(dir: string): Promise { +async function bootHmr(dir: string, root: string[] = []): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dir).href + '/' await ctx.plugin(Loader) await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await ctx.plugin(Hmr, { root, ignored: [], debounce: 0 }) return ctx } @@ -26,6 +26,46 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { + it('observes module changes when its watch base is a filesystem alias', { timeout: 20_000 }, async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) + const alias = `${target}-alias` + const filename = join(alias, 'module.ts') + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(filename, 'export const generation = 0\n') + const ctx = await bootHmr(alias, ['.']) + const expected = pathToFileURL(filename).href + const observed: string[] = [] + ctx.on('hmr/change', (url) => { observed.push(url) }) + try { + const deadline = Date.now() + 10_000 + for (let generation = 1; !observed.includes(expected); generation += 1) { + if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias') + writeFileSync(filename, `export const generation = ${generation}\n`) + await new Promise(resolve => setTimeout(resolve, 20)) + } + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('collapses filesystem aliases before registering an exact watch', async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) + const alias = `${target}-alias` + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = await bootHmr(alias) + try { + await ctx.hmr.registerConfig('plugins.yml', () => {}) + await expect(ctx.hmr.registerConfig(join(realpathSync(target), 'plugins.yml'), () => {})) + .rejects.toThrow('config path already registered') + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) const filename = join(dir, 'plugins.yml') diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index f79f6b2d73..6bed54f5c3 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/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/util/paths/README.md -README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c -README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd +README.md: 8d10ed855a37f1205f87420b3f36f45b10e65bd3 +README.zh.md: ed3ca377bd48252fe0ef3f95186dc6eb1fb6e6a0 diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 2b3272e019..8d10ed855a 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -18,9 +18,13 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. +## Watch paths + +`canonicalizeWatchPath()` gives a native filesystem watcher one stable spelling of its target. It resolves the deepest existing ancestor through `fs.realpath()` and restores any missing suffix, so a file or directory may still be watched before it is created. In particular, Windows 8.3 aliases cannot be mixed with the long paths emitted by the native watcher backend. + This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work - **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged. -- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path. +- **Canonicalization reads but never mutates** — `canonicalizeWatchPath()` performs `realpath` probes and propagates errors other than absence; callers still own directory creation, permissions, and trust policy for the resulting path. diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index 7fe0ec0411..ed3ca377bd 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -18,9 +18,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 +## 监听路径 + +`canonicalizeWatchPath()` 为原生文件系统 watcher 提供一种稳定的目标路径表示。它通过 `fs.realpath()` 解析层级最深的现有祖先路径,再拼回缺失的后缀,因此即使文件或目录尚未创建也仍可监听。尤其是,Windows 8.3 别名不能与原生 watcher 后端发出的长路径混用。 + 该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 ## 已知限制与暂缓事项 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 -- **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 +- **规范化会读取,但绝不修改**:`canonicalizeWatchPath()` 会执行 `realpath` 探测,并传播除路径不存在以外的错误;调用方仍负责目录创建、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index a8f9245c72..591158d7c0 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,8 +4,9 @@ * @module @deepseek-ai/dsh-paths */ +import { realpath } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' /** Directory name for the default DeepSeek Harness home under the OS home. */ export const DSH_HOME_DIR_NAME = '.dsh' @@ -16,6 +17,33 @@ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}` /** Environment variable that overrides the default DeepSeek Harness home. */ export const DSH_HOME_ENV = 'DSH_HOME' +/** + * Give a native filesystem watcher one canonical spelling of a path, even + * when its final components do not exist yet. The deepest existing ancestor + * is resolved through {@link realpath}; the missing suffix is then restored. + * This prevents Windows short-name aliases from being mixed with long paths + * emitted by the native watcher backend. + * @param path - Watch target or root, resolved against the current directory. + * @returns the target with its existing ancestor canonicalized. + * @throws when ancestor traversal encounters an error other than absence. + */ +export async function canonicalizeWatchPath(path: string): Promise { + let current = resolve(path) + const missing: string[] = [] + while (true) { + try { + return join(await realpath(current), ...missing.reverse()) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(current) + /* v8 ignore next -- a filesystem root exists, so traversal resolves before this guard */ + if (parent === current) throw error + missing.push(basename(current)) + current = parent + } + } +} + /** * Resolve the default DeepSeek Harness home using Node's platform path rules. * @returns the absolute default harness home path. diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 08336bbcce..df5555f910 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -1,9 +1,11 @@ -import { homedir } from 'node:os' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, + canonicalizeWatchPath, defaultDshHome, dshHomeDisplay, dshHomePath, @@ -53,4 +55,22 @@ describe('dsh path helpers', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') }) + + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) + const target = join(root, 'target') + const alias = join(root, 'alias') + try { + await mkdir(target) + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe( + join(await realpath(target), 'later', 'config.yml'), + ) + const file = join(root, 'file') + await writeFile(file, 'not a directory') + await expect(canonicalizeWatchPath(join(file, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 89e417558c..3b39b83897 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' import { @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = basename(absPath) placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${basename(absPath)}`, })).toBe('![logo](./logo.svg#view)\n') }) diff --git a/vendor/README.md b/vendor/README.md index 4bf1d43f46..0a434605ae 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md index db5ac1a49a..eec6ff940f 100644 --- a/vendor/hmr/README.md +++ b/vendor/hmr/README.md @@ -7,6 +7,12 @@ module caches, and reloads only the plugin entries that depend on changed application files. Changes to framework-level dependencies fall back to `loader.exit()`, letting the host process restart. +Module watches canonicalize their existing base directory before opening +Chokidar. Exact config watches likewise canonicalize the deepest existing +ancestor, then restore any missing suffix. Callbacks and diagnostics retain the +requested absolute filename, while the native backend receives one filesystem +spelling even when Windows supplied an 8.3 alias. + ## Requirements - `@cordisjs/plugin-loader` diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 00864cd865..c1d379e36c 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -4,7 +4,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plug import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { dirname, relative, resolve } from 'node:path' -import { stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -61,13 +61,18 @@ interface ConfigRegistration { watcher: FSWatcher } -async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { +async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { let root = dirname(filename) let depth = 0 while (true) { try { if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) - return { root, depth } + const canonicalRoot = await realpath(root) + return { + filename: resolve(canonicalRoot, relative(root, filename)), + root: canonicalRoot, + depth, + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(root) @@ -129,9 +134,11 @@ class Hmr extends Service { async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { if (!this.watcher) throw new Error('HMR is not active') filename = resolve(this.baseDir, filename) - if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + const target = await findWatchRoot(filename) + const watchFilename = target.filename + if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`) - const { root, depth } = await findWatchRoot(filename) + const { root, depth } = target const watcher = watch(root, { ...this.config, cwd: undefined, @@ -140,9 +147,10 @@ class Hmr extends Service { ignoreInitial: false, }) const registration = { watcher } - this.configs.set(filename, registration) + this.configs.set(watchFilename, registration) const onChange = (path: string) => { - if (resolve(path) !== filename) return + const observed = resolve(path) + if (observed !== filename && observed !== watchFilename) return this.refreshConfig(registration, filename, refresh) } watcher.on('add', onChange) @@ -167,12 +175,12 @@ class Hmr extends Service { try { await ready.promise return this.ctx.effect(() => async () => { - if (this.configs.get(filename) === registration) this.configs.delete(filename) + if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename) await watcher.close() await this.configRefreshes.get(registration)?.running }, 'hmr.registerConfig()') } catch (error) { - this.configs.delete(filename) + this.configs.delete(watchFilename) await watcher.close() throw error } @@ -205,10 +213,11 @@ class Hmr extends Service { } const match = picomatch(ignored) + const watchBaseDir = await realpath(this.baseDir) this.watcher = watch(root, { ...this.config, - cwd: this.baseDir, - ignored: path => match(relative(this.baseDir, path)), + cwd: watchBaseDir, + ignored: path => match(relative(watchBaseDir, path)), // The initial scan re-announces files the boot just consumed: an `add` // for a config file refreshes an include whose initial apply may still // be in flight, and a failing apply then rolls this plugin back while From df30c62e2b062da14fa0366728d821f416d72e57 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 14:54:31 +0800 Subject: [PATCH 016/262] fix(agent-loop): latch wakes landing in the cancel-convergence window --- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +- .../2026-07-16-explicit-turn-cancellation.md | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 2 +- ...07-cancel-convergence-wake-latch.i18n.yaml | 6 + ...026-08-07-cancel-convergence-wake-latch.md | 29 ++++ ...-08-07-cancel-convergence-wake-latch.zh.md | 29 ++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 24 +-- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 6 +- docs/core-data-structures/core.zh.md | 6 +- docs/event-producer-consumer.md | 24 +-- docs/persistence-catalog.md | 2 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 51 ++++-- packages/core/agent-loop/tests/cancel.spec.ts | 154 ++++++++++++++++-- packages/core/agent-loop/tests/loop.spec.ts | 73 ++++++++- .../core/agent-loop/tests/mock-adapter.ts | 18 +- packages/core/agent/src/types.ts | 6 +- 23 files changed, 379 insertions(+), 77 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 820299cf2e..2f7893a09f 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.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/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 -2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 +2026-07-16-explicit-turn-cancellation.md: 2b7cb8cc77184edf1331764d28aefe748c1614a1 +2026-07-16-explicit-turn-cancellation.zh.md: 68089e2c48d239afbff4c10cba5a202b4b6ff262 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index ca56c77a09..2b7cb8cc77 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -18,7 +18,7 @@ An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outc AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. -The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. +The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index bf410e5c72..68089e2c48 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -18,7 +18,7 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml new file mode 100644 index 0000000000..d31f344c27 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.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/bug-fix/2026-08-07-cancel-convergence-wake-latch.md +2026-08-07-cancel-convergence-wake-latch.md: fe00c78bdfadac0cc6c9c173fd04256f77a22051 +2026-08-07-cancel-convergence-wake-latch.zh.md: f76ad360e2d8b70f76c6baf53e2fde963a80b6c4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md new file mode 100644 index 0000000000..fe00c78bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md @@ -0,0 +1,29 @@ +# Agent Note: Latch wake-ups that land in the cancel-convergence window + +Status: implemented + +English | [中文](2026-08-07-cancel-convergence-wake-latch.zh.md) + +## Problem + +`Agent.cancel(cause, { keepInbox: true })` returns immediately after firing the abort signal, but the active driver may not have converged to `idle` yet: LLM stream teardown, tool cancellation, and the `turn/end` append all unwind asynchronously after `abort()` returns. A waking send arriving in that window was placed into `next-turn` while `wakeDriver()` returned early on the still-`running` phase, and the exiting driver never replayed the wake — the message stayed parked until another waking send arrived. The same dropped-wake window existed around aborted `runMaintenance` activities. Several tests enshrined the parked behavior ("waits for another wakeup"); the bug broke both `session.cancel` and the `subagent.interrupt` composition path (issue #1838). The owning cancellation and send contracts are the [explicit turn cancellation](../architecture/2026-07-16-explicit-turn-cancellation.md) and [unified send](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) decisions; the production `keepInbox` consumer is [web stop preserves queue](2026-07-31-web-stop-preserves-queue.md). + +## Decision + +The `running` phase carries a `wakeRequested` latch, mirroring the existing `maintenance` phase field. `wakeDriver()` latches whenever the current activity cannot deliver the wake — a maintenance task never reads the queue, and an aborted activity converges without restarting — while a live driver needs no latch because it claims queued work itself. The exiting activity replays the latch at its own convergence boundary (`kick`'s `finally` and `runMaintenance`'s `finally`): this placement guarantees `turn/end N` lands before the replayed driver opens `turn/start N+1`, and that `whenIdle()` sees the replayed driver through its `activityDone` loop. The replay sites run only while `inbox.hasPending`, so a latched wake removed from the inbox before convergence does not start an empty driver. A wake sent while the agent is already idle keeps its turn boundary even when its message is cleared before the driver claims — that `idle → running → idle` transition is an observable contract: the goal-session driver's pause/disarm fallback fires on the `idle` transition after a cancelled reservation (CI caught this when the guard was moved into `wakeDriver()` and suppressed the boundary). `cancel()` without `keepInbox` clears the latch together with the inbox. + +The `signal.aborted` discriminator is load-bearing: it separates pre-abort queued work — which `keepInbox` parks for a later wake (acceptance criterion 1) — from post-abort explicit wakes, which must run after convergence. + +## Alternatives considered + +**Have `cancel()` set the phase to `idle` immediately.** Rejected: the driver is still unwinding, so this overlaps two drivers. The replay lives in the old driver's `finally`, which then never runs — 14 of 83 tests failed, several deadlocked. Repairing it requires identity-based phase ownership plus a turn-open quiescence barrier, which is strictly more machinery and is the latch in disguise. + +**Latch unconditionally for every non-idle wake.** Rejected: pre-abort wakes would auto-start after a `keepInbox` cancel, violating acceptance criterion 1; the "parks queued work" test and the error-window steering test both failed. + +**Replay through a chained promise (`activityDone.then(...)`).** Rejected: the replay would run outside the activity's own settlement, so `whenIdle()`'s loop can resolve before the replayed driver starts; fixing that requires replacing `activityDone` at send time and depends on microtask reaction ordering — more fragile than a synchronous flag. + +**Wait for quiescence in the subagent adapter.** Rejected by the issue scope: the cancel/wake state machine owns the fix, not a consumer. + +## Consequences + +The `running` phase gains a `wakeRequested` field; `cancel()` without `keepInbox` clears it alongside the inbox, and a `disposed` cancel never latches, so a wake landing after disposal begins stays parked and `whenIdle()` does not wait on a full model turn over the session being torn down. A wake arriving in the sub-microtask gap between the driver's final `hasPending` check and its exit still parks — no latch fires because the phase is `running` and not aborted; closing that gap requires the unconditional latch and is deliberately out of scope. Between the aborted turn and the replayed driver, status transitions emit a transient `idle → running` pair. A waking send whose message is cleared before any driver claims it still opens an empty completed turn, preserving the observable wake boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md new file mode 100644 index 0000000000..f76ad360e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 锁存取消收敛窗口内到达的唤醒请求 + +Status: implemented + +[English](2026-08-07-cancel-convergence-wake-latch.md) | 中文 + +## 问题 + +`Agent.cancel(cause, { keepInbox: true })` 在触发 abort 信号后立即返回,但活动 driver 可能尚未收敛到 `idle`:LLM 流拆除、工具取消与 `turn/end` 落盘都会在 `abort()` 返回后异步展开。在该窗口内到达的唤醒 send 被放入 `next-turn`,而 `wakeDriver()` 对仍处于 `running` 的 phase 直接返回,退出的 driver 也从不重放这次唤醒——消息会一直停放到下一条唤醒 send 到达。被中止的 `runMaintenance` 活动周围也存在同样的唤醒丢失窗口。多个测试固化了停放行为(「等待下一次唤醒」);该缺陷同时破坏了 `session.cancel` 与 `subagent.interrupt` 组合路径(issue #1838)。拥有取消与发送契约的既有决策是[显式轮次取消](../architecture/2026-07-16-explicit-turn-cancellation.md)与[统一发送](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md);生产环境中的 `keepInbox` 消费方是[Web 停止保留队列](2026-07-31-web-stop-preserves-queue.md)。 + +## 决策 + +`running` phase 携带 `wakeRequested` 锁存,与既有的 `maintenance` phase 字段对称。`wakeDriver()` 在当前活动无法投递唤醒时锁存——maintenance 任务从不读取队列,被中止的活动收敛后不会重启——而存活的 driver 不需要锁存,因为它自己会认领排队的工作。退出中的活动在其自身收敛边界(`kick` 的 `finally` 与 `runMaintenance` 的 `finally`)重放锁存:这一位置保证 `turn/end N` 先于重放 driver 打开 `turn/start N+1` 落盘,并保证 `whenIdle()` 通过其 `activityDone` 循环看到重放 driver。两个重放点仅在 `inbox.hasPending` 时执行,因此收敛前被从 inbox 移除的锁存唤醒不会启动空 driver。而 agent 已处于 idle 时发送的唤醒,即使消息在 driver 认领前被清除,仍会打开自己的 turn 边界——这趟 `idle → running → idle` 转换是可观察契约:goal-session driver 的 pause/disarm 回退依赖取消预订后的 `idle` 转换触发(把守卫放进 `wakeDriver()` 后该边界被抑制,CI 发现了这一点)。不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除锁存。 + +`signal.aborted` 判别项是承重的:它区分「中断前已排队的工作」——`keepInbox` 将其停放以待后续唤醒(验收条件 1)——与「abort 后显式的唤醒」,后者必须在收敛后执行。 + +## 备选方案 + +**让 `cancel()` 立即把 phase 置为 `idle`。** 不予采用:driver 仍在展开收尾,这会重叠两个 driver。重放逻辑位于旧 driver 的 `finally`,而该 `finally` 此后不再执行——83 个测试中有 14 个失败,多个死锁。修复它需要基于身份的 phase 所有权外加 turn 打开时的 quiescence 屏障,机制上严格更重,而且该屏障就是换了个形态的锁存。 + +**对每个非 idle 唤醒无条件锁存。** 不予采用:中断前的唤醒会在 `keepInbox` 取消后自动启动,违反验收条件 1;「停放排队工作」测试与错误窗口的 steering 测试双双失败。 + +**通过链式 promise(`activityDone.then(...)`)重放。** 不予采用:重放会运行在活动自身结算之外,`whenIdle()` 的循环可能在重放 driver 启动前就 resolve;修复它需要在 send 时同步替换 `activityDone`,并依赖微任务反应顺序——比同步 flag 更脆弱。 + +**在 subagent adapter 中等待 quiescence。** 被 issue 范围否决:修复由取消/唤醒状态机拥有,而不是消费方。 + +## 影响 + +`running` phase 新增 `wakeRequested` 字段;不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除它,且 `disposed` 取消从不锁存——dispose 开始后到达的唤醒保持停放,`whenIdle()` 不会在拆除中的会话上等待一个完整模型 turn。落在 driver 最后一次 `hasPending` 检查与退出之间微任务间隙的唤醒仍会停放——没有锁存触发,因为 phase 是 `running` 且未 abort;关闭该间隙需要无条件锁存,刻意留作范围外。在被中止的 turn 与重放 driver 之间,状态转换会发出一次瞬态 `idle → running` 对。唤醒 send 的消息在任何 driver 认领前被清除时,仍会打开一个空的 completed turn,保留可观察的唤醒边界。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ca91a80b7e..de15631197 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 3080d21a24310cc8851d64719d862ec719150ab6 -architecture.zh.md: 6e2bf0c155c68e84580aecc6b7783eeffa25c640 +architecture.md: ea78faa62773a6b8ac98e5baab6e181ad6a3b7f0 +architecture.zh.md: 5a46dfaf84276de5f4dc0352a529bdd4e5d8c267 diff --git a/docs/architecture.md b/docs/architecture.md index 3080d21a24..ea78faa627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Waking input that lands after the abort fires but before convergence runs at the driver's convergence boundary, while a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6e2bf0c155..5a46dfaf84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -121,7 +121,7 @@ idle inject: 适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。abort 触发后、收敛前到达的唤醒输入会在 driver 的收敛边界执行,而 `disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 92c2fade12..ae16d10f07 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -97,7 +97,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discarded` — emit @@ -116,7 +116,7 @@ One message was discarded from the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) ### `agent/inbox/inserted` — emit @@ -135,7 +135,7 @@ One message entered the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — waterfall @@ -158,7 +158,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -182,7 +182,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -209,7 +209,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -231,7 +231,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -252,7 +252,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -281,7 +281,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e79d5dac2f..ee870f9334 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/core.md -core.md: dbd584f10b3daf873bc14210472efb6cd315717e -core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a +core.md: 8f413a7a064ad6f63e0caec31354869e51139020 +core.zh.md: d0f02f0cfc2cd30fc67aacf5b17d1daf324295c5 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dbd584f10b..8f413a7a06 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -685,7 +685,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1fe1616a0c..d0f02f0cfc 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -693,7 +693,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5c60be95e1..940d89eae0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,18 +8,18 @@ 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/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:172`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 533ebd3a97..3845c2cb1a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -100,7 +100,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) ### `approval/*` diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index b25a0f18f1..2f165e76e4 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f -README.zh.md: 5828da301b35c95719286fb942ac239769539b67 +README.md: 2fdc60086bebc924089b5b8bd12f4b4456b1ead5 +README.zh.md: df0ba44ec5b010666737c0568bc5eeb220ff0d96 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ec1948506b..2fdc60086b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -67,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. -Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 5828da301b..df0ba44ec5 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -67,7 +67,7 @@ interface Config { 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 -插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index cfac8262c2..6701777a0f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -43,7 +43,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } type StepEndReason = Extract @@ -112,10 +112,12 @@ export class ReactLoopAgent implements Agent { send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. + // The classification is captured BEFORE the insertion: a reentrant cancel + // from a synchronous splice observer must not reclassify this wake. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) - if (wakeup) this.wakeDriver() + if (wakeup) this.wakeDriver(wakingAfterAbort) } followup(input: UserMessage): void { @@ -133,7 +135,7 @@ export class ReactLoopAgent implements Agent { cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { this.inbox.clear() - if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false + if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } @@ -154,22 +156,44 @@ export class ReactLoopAgent implements Agent { return await task(maintenance.abort.signal) } finally { this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) - if (maintenance.wakeRequested) this.wakeDriver() + if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver() done.resolve() } })() } - /** Start one driver, or remember its wake behind maintenance. */ - private wakeDriver(): void { - if (this.phase.kind === 'maintenance') { - if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true + /** + * Start one driver, or latch its wake behind maintenance or an aborted + * activity. A wake sent while idle always opens its turn boundary, even + * when its message is cleared before the driver claims; only a latched + * replay is suppressed when the queue no longer holds the wake. + * @param wakeAfterAbort - the send-time classification from {@link send}: + * the wake landed after the abort fired. Captured before the inbox + * insertion so a reentrant cancel cannot reclassify it. + */ + private wakeDriver(wakeAfterAbort = false): void { + if (this.phase.kind !== 'idle') { + // The current activity cannot deliver this wake: a maintenance task + // never reads the queue, and an aborted activity converges without + // restarting — both latch for the exiting activity to replay. A live + // driver claims queued work itself, so it needs no latch. A disposal + // cancel never latches: replaying would make `whenIdle()` wait on a + // full model turn over a session being torn down. + const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined + if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { + this.phase.wakeRequested = true + } return } - if (this.phase.kind !== 'idle') return const driver = Promise.withResolvers() this.activityDone = driver.promise - this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 }) + this.setPhase({ + kind: 'running', + abort: new AbortController(), + turn: this.phase.lastTurn, + step: 0, + wakeRequested: false, + }) this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) } @@ -196,7 +220,9 @@ export class ReactLoopAgent implements Agent { } finally { /* v8 ignore next -- kick owns a running phase until this driver boundary */ if (this.phase.kind === 'running') { - this.setPhase({ kind: 'idle', lastTurn: this.phase.turn }) + const { turn, wakeRequested } = this.phase + this.setPhase({ kind: 'idle', lastTurn: turn }) + if (wakeRequested && this.inbox.hasPending) this.wakeDriver() } } } @@ -302,6 +328,9 @@ export class ReactLoopAgent implements Agent { } if (!this.inbox.hasPending) return false phase.abort = new AbortController() + // The driver keeps running with a fresh controller: any latch set on the + // old one is stale, and the live driver claims the queue itself. + phase.wakeRequested = false phase.step = 0 return true } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5c0deed621..79bc65a105 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -126,6 +126,121 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(3) }) + it('cancel({ keepInbox: true }) latches a waking send landing in the abort-to-idle window', async () => { + const adapter = new MockAdapter(['hang', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // The abort signal is set but the driver has not converged to idle yet: + // the waking send must be latched, not parked until another wake. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + e.type === 'turn/end' ? e.data.reason : null)).toEqual([ + { kind: 'aborted', reason: { kind: 'user' } }, + { kind: 'completed' }, + ]) + }) + + it('cancel() without keepInbox clears a latched wake alongside the inbox', async () => { + const adapter = new MockAdapter(['hang', textResponse('C reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') // latched behind the aborted activity + agent.cancel({ kind: 'user' }) // drops the inbox and the latch with it + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + + send(agent, 'C') + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['active', 'C']) + expect(adapter.requests).toHaveLength(2) + }) + + it('removing the latched wake before convergence suppresses the replay', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + const steer = createUserMessage({ content: [{ type: 'text', text: 'steer me' }], source: { kind: 'user' } }) + agent.steer(steer) // latched behind the aborted activity + agent.inbox.remove(steer.id) // the wake is retracted before convergence + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(adapter.requests).toHaveLength(1) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.status).toBe('idle') + // No replay with nothing to run: the latched message is gone, so no + // empty follow-up turn is recorded. + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + }) + + it('latches a wake arriving deep into a slow abort convergence', async () => { + // The stream notices the abort only after 50ms, so the driver stays in + // the abort-to-idle window long after `cancel()` returned: the wake must + // be latched across the whole window, not just the same-tick case. + const adapter = new MockAdapter(['hang-slow', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' }) + + send(agent, 'A') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + await new Promise(resolve => setTimeout(resolve, 10)) + send(agent, 'B') + + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['A', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + }) + + it('does not latch a wake landing after disposal begins', async () => { + const adapter = new MockAdapter(['hang-slow', textResponse('late reply')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('dispose-window-wake'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const agent = handle.agent + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // Dispose cancels with `{ kind: 'disposed' }`; a wake landing in the + // abort-to-idle window must not latch, so `whenIdle()` does not wait on + // a model turn over the session being torn down. + const disposal = handle.dispose() + setTimeout(() => { send(agent, 'late wake') }, 10) + await disposal + + expect(adapter.requests).toHaveLength(1) + expect(userTexts(agent)).toEqual(['active']) + }) + it('cancel after waking send closes its synchronously opened turn without a step', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) @@ -228,7 +343,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) - it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => { + it('replacement work queued after idle-listener cancellation replays at convergence', async () => { const adapter = new MockAdapter([ textResponse('first reply'), textResponse('replacement reply'), @@ -253,9 +368,11 @@ describe('Agent.cancel()', () => { if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') await replacementIdle - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['first']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The wake sent after the cancel fired is latched: the surviving + // replacement runs at convergence without a third message. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const idle = waitForIdle(ctx, agent) send(agent, 'wake it') @@ -479,7 +596,7 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) - it('a running-listener cancellation parks replacement work until another wakeup', async () => { + it('a running-listener cancellation replays replacement work at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -497,17 +614,20 @@ describe('Agent.cancel()', () => { await idle dispose() - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + // B's wake was latched behind the cancelled driver: it runs on its own. + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) }) - it('a prompt queued during pre-step cancellation waits for another wakeup', async () => { + it('a prompt queued during pre-step cancellation replays at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -518,13 +638,15 @@ describe('Agent.cancel()', () => { send(agent, 'B') await idle - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) }) @@ -556,7 +678,7 @@ describe('Agent.cancel()', () => { expect(flat).not.toContain('steer text') }) - it('parks replacement work queued synchronously by an abort observer', async () => { + it('replays replacement work queued synchronously by an abort observer', async () => { const adapter = new MockAdapter([ 'hang', textResponse('replacement reply'), @@ -586,13 +708,15 @@ describe('Agent.cancel()', () => { }), ]) - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['original']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The abort-observer wake was latched: replacement runs at convergence, + // so the original turn is followed by a completed replacement turn. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['original', 'replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const reasons = agent.session.events .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) - expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'wake it') diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c8d048ca47..1173607ae9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -41,6 +41,14 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } +/** All user-message texts recorded in the log (to assert what actually ran). */ +function userTexts(agent: Agent): string[] { + return agent.session.events + .filter(e => e.type === 'user/message') + .flatMap(e => e.type === 'user/message' ? e.data.content : []) + .flatMap(b => b.type === 'text' ? [b.text] : []) +} + describe('agent loop', () => { it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid AgentOptions.maxTokens %s before publication', @@ -70,7 +78,7 @@ describe('agent loop', () => { }) it('cancels queued wakeup work together with an active maintenance task', async () => { - const adapter = new MockAdapter([textResponse('unused')]) + const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { provider: 'mock', @@ -87,15 +95,68 @@ describe('agent loop', () => { }) await started.promise - send(agent, 'discard this wakeup') - agent.cancel({ kind: 'user' }) - send(agent, 'park after cancellation') + send(agent, 'discard this wakeup') // latched behind the live maintenance task + agent.cancel({ kind: 'user' }) // drops the queue and the latch, aborts maintenance + send(agent, 'park after cancellation') // newer intent: re-latched, replays at convergence await expect(maintenance).rejects.toThrow('maintenance aborted') await agent.whenIdle() - expect(agent.inbox.nextTurn).toHaveLength(1) + + // The pre-cancel wakeup is gone; the post-cancel wake replays at convergence. + expect(userTexts(agent)).toEqual(['park after cancellation']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('replays a wake latched behind maintenance at convergence', async () => { + const adapter = new MockAdapter([textResponse('wake reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + send(agent, 'wake behind maintenance') + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['wake behind maintenance']) + expect(adapter.requests).toHaveLength(1) + }) + + it('suppresses the replay when a latched maintenance wake is removed', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + const wake = createUserMessage({ content: [{ type: 'text', text: 'removed wake' }], source: { kind: 'user' } }) + agent.followup(wake) + agent.inbox.remove(wake.id) + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - agent.cancel({ kind: 'user' }) + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 6e592d9311..69dc78990c 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -58,14 +58,16 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a - * function to compute chunks from the request, or a 'hang' marker that - * streams one chunk then waits until aborted. + * function to compute chunks from the request, a 'hang' marker that + * streams one chunk then waits until aborted, or 'hang-slow' which takes + * 50ms to notice the abort — a stand-in for slow real-world teardown + * (LLM stream cancellation, tool unwinding). */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -98,6 +100,16 @@ export class MockAdapter extends LlmAdapter { }) return } + if (entry === 'hang-slow') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + const fail = (): void => { reject(new Error('aborted')) } + if (options.signal?.aborted) { setTimeout(fail, 50); return } + options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true }) + }) + return + } const chunks = typeof entry === 'function' ? entry(options) : entry for (const chunk of chunks) { if (options.signal?.aborted) throw new Error('aborted') diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fae9267347..d8cb5c4114 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -104,7 +104,11 @@ export interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. From 853f929704222cf5ce186cbce277c6fa298c7f59 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 16:25:23 +0800 Subject: [PATCH 017/262] refactor(agent-loop): tighten wake-latch comments --- packages/core/agent-loop/src/agent.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6701777a0f..6ef965e59e 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -112,8 +112,7 @@ export class ReactLoopAgent implements Agent { send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. - // The classification is captured BEFORE the insertion: a reentrant cancel - // from a synchronous splice observer must not reclassify this wake. + // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) @@ -165,20 +164,16 @@ export class ReactLoopAgent implements Agent { /** * Start one driver, or latch its wake behind maintenance or an aborted * activity. A wake sent while idle always opens its turn boundary, even - * when its message is cleared before the driver claims; only a latched - * replay is suppressed when the queue no longer holds the wake. - * @param wakeAfterAbort - the send-time classification from {@link send}: - * the wake landed after the abort fired. Captured before the inbox - * insertion so a reentrant cancel cannot reclassify it. + * when its message was cleared; only a latched replay is suppressed when + * the queue no longer holds the wake. + * @param wakeAfterAbort - the {@link send} classification, captured before + * the inbox insertion so a reentrant cancel cannot reclassify it. */ private wakeDriver(wakeAfterAbort = false): void { if (this.phase.kind !== 'idle') { - // The current activity cannot deliver this wake: a maintenance task - // never reads the queue, and an aborted activity converges without - // restarting — both latch for the exiting activity to replay. A live - // driver claims queued work itself, so it needs no latch. A disposal - // cancel never latches: replaying would make `whenIdle()` wait on a - // full model turn over a session being torn down. + // Maintenance and aborted drivers cannot deliver the wake: latch it for + // replay at convergence. Live drivers claim queued work themselves; + // disposal never latches, so teardown waits on no model turn. const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { this.phase.wakeRequested = true @@ -328,8 +323,7 @@ export class ReactLoopAgent implements Agent { } if (!this.inbox.hasPending) return false phase.abort = new AbortController() - // The driver keeps running with a fresh controller: any latch set on the - // old one is stale, and the live driver claims the queue itself. + // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself. phase.wakeRequested = false phase.step = 0 return true From bdbc6c3da4b2579973574a0bea54a4d851b32a8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:57:31 +0800 Subject: [PATCH 018/262] fix(windows): preserve graceful CLI shutdown --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 + ...08-08-native-windows-pull-request-ci.zh.md | 2 + apps/cli/src/profile-boot.ts | 69 +++++++++++-------- apps/cli/tests/built-bin.e2e.ts | 37 +++++++--- 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index c9d06abacf..b1b0079115 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 8b6766cfa8df48c6c61410c06a410340a0c9bb21 -2026-08-08-native-windows-pull-request-ci.zh.md: 29cb1f725f4212c13974d642388edaf1df16b759 +2026-08-08-native-windows-pull-request-ci.md: 80fccabdc9ddebba2204732677da31363b74a184 +2026-08-08-native-windows-pull-request-ci.zh.md: 9d00f4fad6eb817968609c3af1f278cc6794e7c7 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 8b6766cfa8..80fccabdc9 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -16,6 +16,8 @@ The aggregate keeps workspace build and production-site failures blocking while The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. +The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 29cb1f725f..9d00f4fad6 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -16,6 +16,8 @@ Status: implemented 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 +下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 4730ec7073..b49bd37535 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import { FiberState, type Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { boot, @@ -168,6 +168,10 @@ export interface RunProfileOptions { environment: EnvironmentSnapshot } +function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { + if (!signal.aborted) throw error +} + /** * Boot one profile invocation end to end and leave process lifetime to the * mounted plugins (or to the one-shot runner when `task` is present). @@ -196,11 +200,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const app: { current?: Context } = {} const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) + const signalShutdown = new AbortController() + const interrupt = (code: number): void => { + signalShutdown.abort() + shutdown.interrupt(code) + } // Signals own teardown throughout the startup window, not only after boot() // settles: an inserted front door can publish readiness before sibling rows // finish mounting. - process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) }) - process.on('SIGINT', () => { shutdown.interrupt(130) }) + process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() }) @@ -243,33 +252,37 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con await options.prepare?.(hostCtx, composed.rows) }) app.current = ctx - // A surface can dispose the whole tree while startup was still in flight - // (early SIGTERM); the Loader service goes with it and there is nothing to - // keep live. - if (watchProfilePatch && ctx.get('loader') !== undefined) { - // Config-only HMR for the live profile patch layer: the web bundle - // disables the shared module-reload `hmr` row (its reload lifecycle is - // untested), so when the composition leaves no HMR service, mount a - // watch-only instance with no module roots — cordis.patch.yml edits stay - // live on every long-lived surface. A silent skip would break the - // documented hot-reload contract. HMR injects the timer service, which a - // bare custom profile may not mount either. - if (ctx.get('hmr') === undefined) { - if (ctx.get('timer') === undefined) { - await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + // A surface can dispose the whole tree while startup or this post-boot + // watcher setup is still in flight. Fiber state owns liveness; the local + // signal fact distinguishes that expected exit race from a real HMR error. + if (watchProfilePatch && !signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE) { + try { + // Config-only HMR for the live profile patch layer: the web bundle + // disables the shared module-reload `hmr` row (its reload lifecycle is + // untested), so when the composition leaves no HMR service, mount a + // watch-only instance with no module roots — cordis.patch.yml edits stay + // live on every long-lived surface. A silent skip would break the + // documented hot-reload contract. HMR injects the timer service, which a + // bare custom profile may not mount either. + if (ctx.get('hmr') === undefined) { + if (ctx.get('timer') === undefined) { + await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + } + await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) } - await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) + await watchUserPatches(ctx, { + binName: NAME, + filename: composed.profile.patchPath, + compose: composeLive, + }) + await watchUserPatches(ctx, { + binName: NAME, + filename: homePatchPath(), + compose: composeLive, + }) + } catch (error) { + suppressSignalShutdownError(signalShutdown.signal, error) } - await watchUserPatches(ctx, { - binName: NAME, - filename: composed.profile.patchPath, - compose: composeLive, - }) - await watchUserPatches(ctx, { - binName: NAME, - filename: homePatchPath(), - compose: composeLive, - }) } return { ctx, shutdown } } diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a42780581f..05e248a8f6 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -49,6 +49,7 @@ interface ProfileLifecycleFixture { ready: string settled: string disposed: string + interrupt: string } /** @@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { const ready = join(home, 'ready') const settled = join(home, 'settled') const disposed = join(home, 'disposed') + const interrupt = join(home, 'interrupt') const bundleDir = join(home, 'lifecycle-bundle') mkdirSync(bundleDir, { recursive: true }) writeFileSync(join(bundleDir, 'plugin.mjs'), [ - "import { writeFileSync } from 'node:fs'", + "import { existsSync, writeFileSync } from 'node:fs'", "import { join } from 'node:path'", "export const name = 'profile-lifecycle-fixture'", 'export function apply(ctx, config = {}) {', ' let active = true', ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.', - ' const heartbeat = setInterval(() => {}, 1000)', + ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', ' // Echo the mounted generation so the hot-reload e2e can assert both an', ' // applied override and its removal reverting to this bundle default.', " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", @@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) { writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file))) } - return { home, ready, settled, disposed } + return { home, ready, settled, disposed, interrupt } } function startProfileLifecycle(fixture: ProfileLifecycleFixture) { @@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, + RAW_INTERRUPT_FILE: fixture.interrupt, }, }) } +function requestProfileShutdown( + child: ReturnType, + fixture: ProfileLifecycleFixture, +): void { + if (process.platform === 'win32') { + writeFileSync(fixture.interrupt, 'interrupt') + return + } + child.kill('SIGTERM') +} + function createEnvironmentProbeProfile(home: string, project: string): void { const pluginFile = join(project, 'environment-probe.mjs') writeFileSync(pluginFile, [ @@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void { " if (chunk.type === 'text-delta') text += chunk.text", ' }', ' process.stdout.write(`${text}\\n`)', - " process.kill(process.pid, 'SIGTERM')", + " if (process.platform === 'win32') process.emit('SIGTERM')", + " else process.kill(process.pid, 'SIGTERM')", ' })', '}', '', @@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = startProfileLifecycle(fixture) try { await waitForFile(fixture.ready) - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) const result = await child - expect(result.exitCode).toBe(0) + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0) expect(result.signal).toBeUndefined() expect(existsSync(fixture.disposed)).toBe(true) } finally { @@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ].join('\n')) await waitForFile(fixture.ready) expect(readFileSync(configFile, 'utf8')).toBe('home') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) const result = await child - expect(result.exitCode).toBe(0) + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0) expect(result.signal).toBeUndefined() expect(existsSync(fixture.disposed)).toBe(true) } finally { From 66a21a38b13a051af3d856cde2033e84af5a3aba Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 11:59:19 +0800 Subject: [PATCH 019/262] feat(subagent): add current-turn interrupt RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctx.subagents.interrupt() stops one live continuable child's current turn via Agent.cancel(cause, { keepInbox: true }) under either a human durable parent address or an exact live ancestor Agent. Fire-and-return: admission is synchronous, quiescence is not awaited. Pending inbox work, the Activation, and published descendants are preserved; only a later waking send resumes the parked FIFO queue. Absent, one-shot, and disposing targets are accepted no-ops. The new Host RPC subagent.interrupt calls only that primitive with user authority — no catalog, history, persistence, or parent-registry lookup — so a live child stays stoppable while its parent Agent is offline. Refs #1535 --- ...6-continuable-subagent-interrupt.i18n.yaml | 6 + ...26-08-06-continuable-subagent-interrupt.md | 42 ++++ ...08-06-continuable-subagent-interrupt.zh.md | 42 ++++ docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 20 +- .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/tests/fake-api.ts | 3 + packages/client/runtime/tests/fake-api.ts | 4 + .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/host/apiproxy/src/api-proxy.ts | 25 ++ packages/host/apiproxy/src/api/index.ts | 3 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/subagents.schema.ts | 12 + packages/host/apiproxy/src/api/subagents.ts | 18 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../tests/api-proxy-subagents.spec.ts | 55 ++++- .../apiproxy/tests/client-handler.spec.ts | 27 +++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 7 +- packages/subagent/subagent/README.zh.md | 7 +- .../subagent/subagent/src/continuation.ts | 71 ++++++ packages/subagent/subagent/src/index.ts | 20 ++ .../subagent/tests/continuation.spec.ts | 220 ++++++++++++++++++ .../subagent/subagent/tests/service.spec.ts | 10 + scripts/gen-cordis-catalog.ts | 1 + 27 files changed, 613 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml new file mode 100644 index 0000000000..fe6e8870f5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.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-06-continuable-subagent-interrupt.md +2026-08-06-continuable-subagent-interrupt.md: 729f1eb8259aa28aa771ed71872ebce9cadd4ed8 +2026-08-06-continuable-subagent-interrupt.zh.md: 15fe34d8b5e1d67623aabfe08710f341739e92e1 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md new file mode 100644 index 0000000000..729f1eb825 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -0,0 +1,42 @@ +# Agent Note: Continuable subagent current-turn interrupt + +Status: implemented + +English | [中文](2026-08-06-continuable-subagent-interrupt.zh.md) + +## Problem + +A running continuable subagent could not be stopped without destroying it. The continuation manager cancels child Agents only inside whole-Activation teardown (settlement, drain, scoped drain), `send_message`/`subagent.prompt` only add work, and the Web composer's Stop button was deliberately limited to ordinary sessions. A human watching a continuable child burn tokens on a wrong path had no lever short of killing the parent tree, and when the direct parent Agent was offline the child was entirely untouchable even though its Activation stayed live. One-shot runs have holder-owned disposal and task-kill; continuable children had no analogous current-turn control. + +## Decision + +`ctx.subagents.interrupt(targetSessionId, authority)` stops only the live target's current turn. The manager primitive authorizes synchronously, calls the existing `Agent.cancel(cause, { keepInbox: true })`, and returns `void` — fire-and-return: the cancel signal is guaranteed issued, target quiescence is not awaited. Nothing else changes: no Activation disposal, no handle release, no descendant cascade, no inbox clearing, and no `AgentLoop` or `CancelOptions` change. Because `keepInbox` parks the pending queue at idle, an interrupt never auto-starts the next queued follow-up; only a later explicit waking send resumes the preserved FIFO order. + +Authority is a closed two-variant union, deliberately wider than delivery authority because stopping a turn is idempotent and delivers no content: + +- `{ kind: 'user', parentSessionId }` — a human presents the durable direct-parent address. The live target's `session.header.parentSession` must match; no live parent Agent, catalog read, or persistence access is involved, which is exactly what keeps a live child stoppable while its parent Agent is offline. Cancel cause `user`. +- `{ kind: 'ancestor', agent }` — an exact live ancestor Agent (direct parent or deeper). The caller must be the registry's current entry for its id (stale callers are rejected even for absent targets), must not be the target itself, and must appear in the Activation's materialization-time `ancestry` WeakSet. Cancel cause `parent`. + +Targets are resolved only in the manager's process-local Activation map. An absent id — unknown, one-shot, or naturally settled — is an accepted no-op, which uniformly covers completion races and repeat requests without leaking durable-catalog information; a target whose disposal transaction is already open is likewise an accepted no-op after authorization. One-shot lifecycle (holder `dispose()`, task-kill) is untouched. `SubagentService.interrupt()` treats a manager-less composition as an accepted no-op rather than `CONTINUATION_UNAVAILABLE`, because without a manager no manager-owned live Activation can exist. + +The Host RPC `subagent.interrupt` takes the continuable `SubagentAddress` and returns `{ accepted: true }`. Its implementation calls only the core primitive with `user` authority — deliberately no `catalogChild()`, `listChildren()`, `sessionQuery`, or parent-registry lookup. A live target with a mismatched parent address maps to `subagent-unauthorized`; unexpected failures map to `internal` without leaking error text onto the wire. + +## Alternatives considered + +**Route human interrupts through `session.cancel`.** The generic session cancel requires an attached ordinary session and rejects subagent-owned sessions; widening it would entangle subagent authority rules with ordinary session routing. A subagent-domain RPC keeps the address-based authorization and the parent-offline guarantee explicit. + +**Await target quiescence and return the turn outcome.** Cancellation is cooperative, so quiescence is unbounded; holding the RPC (and a `ChildLock` slot) open invites timeouts and convoying against delivery and disposal. Acceptance-of-signal is the only fact the caller needs, and races (natural completion, disposal) already settle idempotently. + +**Reuse whole-Activation disposal for interrupt.** Disposal cancels without `keepInbox`, flushes, captures, and releases the handle — it destroys queued work and the child's residency. Interrupt is a control operation on one turn, not a lifecycle operation on the Activation. + +**Extend `send_message`/`followup` authority to ancestors while at it.** Delivery injects content into a conversation and is not idempotent; its exact-direct-parent authority stays unchanged. Only interrupt gets the wider ancestor and address-based user authority. + +**Auto-resume the parked queue after an interrupt.** Immediately starting queued follow-up B after aborting A would make the interrupt look ignored and steal the human's window to redirect the child. Parking until an explicit waking send keeps the stop observable and the FIFO order intact. + +## Consequences + +A human or ancestor can now stop a runaway continuable turn without losing the child, its queued work, or its running descendants; the cost is a deliberately weak postcondition (`accepted` means "signal issued", so a target may remain visibly `running` until it observes the signal) that clients must render honestly. The parked-queue rule means an interrupted child sits idle with retained work until someone sends a waking message — an intentional human-in-the-loop pause, not a scheduler defect. The Web Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. + +## Testing + +Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md new file mode 100644 index 0000000000..15fe34d8b5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Continuable subagent 当前轮次中断 + +Status: implemented + +[English](2026-08-06-continuable-subagent-interrupt.md) | 中文 + +## Problem + +一个正在运行的 continuable subagent 无法在不销毁它的前提下被停止。继续执行管理器只在整个 Activation 拆除(结算、drain、scoped drain)内部取消子 Agent,`send_message`/`subagent.prompt` 只能增加工作,而 Web composer 的 Stop 按钮被刻意限制在普通会话。人类眼看着一个 continuable child 在错误路径上烧 token,除了干掉整个 parent 树没有任何手段;当直接 parent Agent 离线时,即使 child 的 Activation 仍然在线,它也完全不可触及。一次性运行有持有方拥有的 disposal 和 task-kill;continuable child 没有对应的当前轮次控制。 + +## Decision + +`ctx.subagents.interrupt(targetSessionId, authority)` 只停止在线目标的当前轮次。管理器原语同步完成鉴权,调用现有的 `Agent.cancel(cause, { keepInbox: true })`,然后返回 `void`——fire-and-return:保证取消信号已发出,但不等待目标静止。其余一切不变:不 dispose Activation、不释放 handle、不级联后代、不清空 inbox,也不改动 `AgentLoop` 或 `CancelOptions`。由于 `keepInbox` 让待处理队列停在 idle,中断绝不会自动启动下一个排队的 follow-up;只有之后一次显式唤醒发送才按保留的 FIFO 顺序恢复。 + +授权是一个封闭的双变体 union,刻意比投递权限更宽,因为停止一个轮次是幂等的且不投递任何内容: + +- `{ kind: 'user', parentSessionId }`——人类出示持久化直接 parent 地址。在线目标的 `session.header.parentSession` 必须匹配;不涉及在线 parent Agent、目录读取或持久化访问,这正是 parent Agent 离线时在线 child 仍可被停止的原因。取消 cause 为 `user`。 +- `{ kind: 'ancestor', agent }`——一个确切在线的 ancestor Agent(直接 parent 或更深)。调用方必须是注册表中其 id 的当前条目(过期调用方即使目标不存在也被拒绝),不得是目标本身,并且必须出现在 Activation 物化时记录的 `ancestry` WeakSet 中。取消 cause 为 `parent`。 + +目标只在管理器进程本地的 Activation map 中解析。不存在的 id——未知、一次性或已自然结算——是被接受的 no-op,统一覆盖完成竞态和重复请求而不泄露持久化目录信息;disposal 事务已打开的目标在鉴权后同样是被接受的 no-op。一次性生命周期(持有方 `dispose()`、task-kill)不受影响。`SubagentService.interrupt()` 把未绑定管理器的组合视为被接受的 no-op 而不是 `CONTINUATION_UNAVAILABLE`,因为没有管理器就不可能存在管理器拥有的在线 Activation。 + +Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 `{ accepted: true }`。它的实现只以 `user` 授权调用核心原语——刻意不调用 `catalogChild()`、`listChildren()`、`sessionQuery` 或 parent 注册表查找。parent 地址不匹配的在线目标映射为 `subagent-unauthorized`;意外失败映射为 `internal`,不把错误文本泄漏到 wire。 + +## Alternatives considered + +**让人类中断走 `session.cancel`。** 通用会话取消要求附着的普通会话并拒绝 subagent 拥有的会话;放宽它会把 subagent 权限规则缠进普通会话路由。subagent 域的 RPC 让基于地址的鉴权和 parent 离线保证保持显式。 + +**等待目标静止并返回轮次结果。** 取消是协作式的,静止时间无上界;让 RPC(以及一个 `ChildLock` 槽位)保持打开会招致超时并与投递、disposal 形成排队。调用方需要的唯一事实是信号已被接受,而竞态(自然完成、disposal)本就幂等收敛。 + +**复用整个 Activation 的 disposal 来做中断。** disposal 的取消不带 `keepInbox`,还会 flush、capture 并释放 handle——它销毁排队工作和 child 的驻留。中断是针对一个轮次的控制操作,不是针对 Activation 的生命周期操作。 + +**顺手把 `send_message`/`followup` 权限扩展到 ancestor。** 投递向对话注入内容且不幂等;其确切直接 parent 权限保持不变。只有中断获得更宽的 ancestor 与基于地址的用户授权。 + +**中断后自动恢复被暂停的队列。** 在中止 A 后立即启动排队的 follow-up B 会让中断看起来被忽略,并夺走人类重新引导 child 的窗口。暂停到显式唤醒发送为止,让停止可观察且 FIFO 顺序完整。 + +## Consequences + +人类或 ancestor 现在可以停止一个失控的 continuable 轮次,而不丢失 child、其排队工作或正在运行的后代;代价是一个刻意保持弱的后置条件(`accepted` 表示"信号已发出",目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle,直到有人发送唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 + +## Testing + +`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ae16d10f07..edfd046430 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -699,7 +699,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -716,7 +716,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -731,7 +731,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -753,7 +753,7 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a22c3464eb..caf081b5a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2071,6 +2071,22 @@ async startContinuable(spec: ContinuableStartSpec): Promise */ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +/** + * Interrupt one live continuable child's current turn under a human parent + * address or an exact live ancestor Agent. Fire-and-return: the cancel + * signal is issued before this returns, but the target may keep running + * until it observes the signal. Pending inbox work, the Activation, and + * published descendants are preserved; only a later waking send resumes the + * parked FIFO queue. An absent target — including a one-shot or unknown id — + * is an accepted no-op, as is a manager-less composition, which cannot own a + * live Activation. + * @param targetSessionId - the durable child session id to interrupt. + * @param authority - the human parent address or exact live ancestor Agent. + * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the + * live target. + */ +interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void + /** * Deliver selected content from one live continuable child to its durable * direct parent. The child is the authority credential; callers cannot name a @@ -2171,9 +2187,9 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentInterruptAuthority](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ec3199ee02..2a081284b2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { prompt: request => Promise.resolve(ok(request, { messageId: `fixture-message-${request.payload.childSessionId}` as never, })), + interrupt: request => Promise.resolve(ok(request, { accepted: true as const })), }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), @@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'subagent.list': return this.api.subagents.list(request) case 'subagent.history': return this.api.subagents.history(request) case 'subagent.prompt': return this.api.subagents.prompt(request, signal) + case 'subagent.interrupt': return this.api.subagents.interrupt(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc4504e538..ef5071316e 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient { prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({ messageId: 'fake-message' as never, }))), + interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({ + accepted: true as const, + }))), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 2f4299ce6c..5e510c0012 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient { onSubagentPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ messageId: 'fake-message' as never })) + onSubagentInterrupt: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + readonly subagents: IApiClient['subagents'] = { list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)), history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)), prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)), + interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dec95f3b5f..acaefa5d5f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -928,6 +928,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */', }, + { + signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', + jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Pending inbox work, the Activation, and\n * published descendants are preserved; only a later waking send resumes the\n * parked FIFO queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */', + }, { signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise', jsDoc: '/**\n * Deliver selected content from one live continuable child to its durable\n * direct parent. The child is the authority credential; callers cannot name a\n * recipient. Reporting does not conclude the child\'s turn or Activation.\n * @param child - exact live reporting child.\n * @param content - selected model-facing content.\n * @param options - parent scheduling and pre-acceptance cancellation.\n * @returns the stable identity of the parent-accepted message.\n * @throws when continuation services are unavailable, sender authorization\n * fails, or the direct parent is not live.\n */', @@ -2787,6 +2791,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentFollowupOptions', declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', }, + { + name: 'SubagentInterruptAuthority', + declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};', + }, { name: 'SubagentListEntry', declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index cfcae423bc..52236b0f29 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2013,6 +2013,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return subagentPromptError(request, error, signal) } }, + + // Deliberately no catalog, history, persistence, or parent Agent lookup: + // the core primitive alone authorizes the durable address against the + // live Activation, which is what keeps a live child interruptible while + // its parent Agent is offline. Absent targets are accepted no-ops there. + interrupt(request) { + const { parentSessionId, childSessionId } = request.payload + try { + ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId }) + } catch (error: unknown) { + if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { + return Promise.resolve(err(request, { + code: 'subagent-unauthorized', + message: 'subagent does not belong to this parent', + details: { childSessionId }, + })) + } + return Promise.resolve(err(request, { + code: 'internal', + message: 'subagent interrupt failed', + details: {}, + })) + } + return Promise.resolve(ok(request, { accepted: true as const })) + }, }, workspace: { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index cb83c5328d..5537a0e382 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -42,7 +42,8 @@ export type { } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { - SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, + SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, + SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..deb963db07 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -36,6 +36,7 @@ export interface RpcMethodMap { 'subagent.list': SubagentsApi['list'] 'subagent.history': SubagentsApi['history'] 'subagent.prompt': SubagentsApi['prompt'] + 'subagent.interrupt': SubagentsApi['interrupt'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.listDirectory': HostApi['listDirectory'] diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts index 1987d568d3..6ed8bd3263 100644 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({ content: z.array(contentBlockSchema), }) as unknown as z.ZodType> +/** subagent.interrupt request payload. */ +export const subagentInterruptRequestSchema = z.object({ + parentSessionId: sessionIdSchema, + childSessionId: sessionIdSchema, + mode: z.literal('continuable'), +}) satisfies z.ZodType>> + +/** subagent.interrupt response value. */ +export const subagentInterruptValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> + const messageIdSchema = z.string() as unknown as z.ZodType /** subagent.prompt response value. */ diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 4c251dca7c..8efb452c2e 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -40,6 +40,11 @@ export interface SubagentPromptReceipt { messageId: MessageId } +/** Uniform acknowledgement that one interrupt request was admitted. */ +export interface SubagentInterruptReceipt { + accepted: true +} + /** Durable parent/child address that selects subagent transport in the client. */ export type SubagentAddress = & { @@ -94,4 +99,17 @@ export interface SubagentsApi { >, signal: AbortSignal, ): Promise> + + /** + * Interrupts a live continuable child's current turn under the address's + * durable direct-parent authority, without requiring a live parent Agent, + * consulting the catalog, or resuming anything. Fire-and-return: `accepted` + * acknowledges the admitted cancel signal, not target quiescence, so the + * child may remain visibly running briefly. Queued follow-ups are kept and + * parked; an absent, idle, or already-completed target is likewise + * `accepted`. + */ + interrupt( + request: RpcRequest>, + ): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..0ce935809f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -58,6 +58,7 @@ import { import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { subagentHistoryValueSchema, + subagentInterruptValueSchema, subagentListValueSchema, subagentPromptValueSchema, } from '../api/subagents.schema.ts' @@ -96,6 +97,7 @@ export interface IApiClient { list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> + interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -171,6 +173,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('subagent.list', payload, signal), history: (payload, signal) => this.callUnary('subagent.history', payload, signal), prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal), + interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..bd3bc3827a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -60,6 +60,7 @@ import { import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { subagentHistoryRequestSchema, + subagentInterruptRequestSchema, subagentListRequestSchema, subagentPromptRequestSchema, } from '../api/subagents.schema.ts' @@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, 'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) }, 'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) }, + 'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index feb9ecb073..62c7e653e0 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -19,6 +19,7 @@ function bench(options: { childStatus?: 'idle' | 'running' entries?: object[] followupError?: Error + interruptError?: Error listError?: Error /** Persistence forgets the child entirely (the vanished-mid-read race). */ storedChild?: false @@ -53,6 +54,12 @@ function bench(options: { ) => options.followupError === undefined ? Promise.resolve('message-1') : Promise.reject(options.followupError)) + const interrupt = vi.fn(( + _targetSessionId: SessionId, + _authority: { kind: 'user'; parentSessionId: SessionId }, + ) => { + if (options.interruptError !== undefined) throw options.interruptError + }) const childHeader = { version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT, } satisfies SessionHeader @@ -72,7 +79,7 @@ function bench(options: { }) const ctx = new Context() ctx.provide('agents', { get: getAgent }) - ctx.provide('subagents', { listChildren, followup }) + ctx.provide('subagents', { listChildren, followup, interrupt }) ctx.provide('sessions', { get: (id: SessionId) => options.liveChild === true && id === CHILD ? { id: CHILD, header: childHeader, events: childEvents } @@ -90,7 +97,7 @@ function bench(options: { const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', }) - return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } + return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent } } describe('subagent gateway', () => { @@ -309,4 +316,48 @@ describe('subagent gateway', () => { error: { code: 'internal', message: 'subagent prompt failed' }, }) }) + + it('interrupts through the core primitive alone while the parent Agent is offline', async () => { + const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false }) + const response = await api.subagents.interrupt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, + })) + expect(response.rpcId).toBe('subagent-rpc') + expect(response.result).toEqual({ ok: true, value: { accepted: true } }) + expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT }) + // No parent-registry, catalog, or history dependency: this is what keeps a + // live child interruptible after its parent Agent went offline. + expect(getAgent).not.toHaveBeenCalled() + expect(listChildren).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() + }) + + it('maps interrupt authorization rejection without touching other services', async () => { + const { api, listChildren } = bench({ + interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'), + }) + const response = await api.subagents.interrupt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, + })) + expect(response.result).toEqual({ + ok: false, + error: { + code: 'subagent-unauthorized', + message: 'subagent does not belong to this parent', + details: { childSessionId: CHILD }, + }, + }) + expect(listChildren).not.toHaveBeenCalled() + }) + + it('hides unexpected interrupt failures behind the internal code', async () => { + const { api } = bench({ interruptError: new Error('secret activation state') }) + const response = await api.subagents.interrupt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, + })) + expect(response.result).toEqual({ + ok: false, + error: { code: 'internal', message: 'subagent interrupt failed', details: {} }, + }) + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ebd56ee551..476ebf344f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -63,6 +63,7 @@ function scriptedApi(overrides: { list: r => ok(r, { entries: [], parentAvailable: false }), history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { messageId: 'message-1' as never }), + interrupt: r => ok(r, { accepted: true as const }), ...overrides.subagents, }, host: { @@ -248,6 +249,32 @@ describe('unary round trip', () => { } }) + it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => { + const interrupt = vi.fn((r: RpcRequest) => ok(r, { accepted: true as const })) + const api = scriptedApi({ subagents: { interrupt } }) + const c = client(api) + + const accepted = await c.subagents.interrupt({ + parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable', + }) + expect(accepted.result).toEqual({ ok: true, value: { accepted: true } }) + expect(interrupt).toHaveBeenCalledTimes(1) + + // The wire schema owns the mode fence: a one-shot address never reaches the impl. + const oneShot = await c.subagents.interrupt({ + parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot', + } as never) + expect(oneShot.result.ok).toBe(false) + if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request') + + const incomplete = await c.subagents.interrupt({ + parentSessionId: sid('parent'), mode: 'continuable', + } as never) + expect(incomplete.result.ok).toBe(false) + if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request') + expect(interrupt).toHaveBeenCalledTimes(1) + }) + it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 6481d75837..83ada22644 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { messageId: 'message-1' as never } }, } }, + async interrupt(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } + }, }, host: { async describe(request) { @@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => { mode: 'continuable', content: [], })).result).toEqual({ ok: true, value: { messageId: 'message-1' } }) + expect((await c.subagents.interrupt({ + parentSessionId: 'parent' as never, + childSessionId: 'child' as never, + mode: 'continuable', + })).result).toEqual({ ok: true, value: { accepted: true } }) }) it('keeps caller and connection aborts on command.execute', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f5ceedc743..250fd1a078 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829 -README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902 +README.md: 7de3a5563b274e925fba931a6d5de17e68cc397c +README.zh.md: 6067555544ec0c32729beb2b4e3f773e31b747a1 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9d2e38c873..7de3a5563b 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,6 +18,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | +| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Pending inbox work, the Activation, and published descendants are preserved; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. | | `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | @@ -76,7 +77,7 @@ Run events are scoped to the delegating parent. Every listener is independently Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order. -Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. +Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content. When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. @@ -84,7 +85,7 @@ When `ctx.sessionProjections` is available, the service registers two projection ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task or result promise — a caller sends later work with the `send_message` follow-up tool, while `interrupt()` stops only the current turn without disposing the child. The durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. @@ -99,7 +100,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. -- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability. +- **No host-user continuation** — `followup()` requires the exact live direct parent. Only `interrupt()` accepts a durable parent-address user authority, because stopping a turn is idempotent and delivers no content; a future host adapter needs a concrete authenticated interaction before the seam gains a user delivery capability. - **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 341c18617a..6067555544 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -18,6 +18,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | +| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent(`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入是同步的、生效是异步的:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。待处理的 inbox 工作、Activation 与已发布的后代均保持不变;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 | | `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | @@ -76,7 +77,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 -可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 +可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代——因为停止一个轮次是幂等的,且不投递任何内容。 当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。 @@ -84,7 +85,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task 或结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,而 `interrupt()` 只停止当前轮次,不 dispose 子 agent。持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 @@ -99,7 +100,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 已知限制与暂缓事项 - **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 -- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。 +- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。只有 `interrupt()` 接受持久化 parent 地址形式的用户授权,因为停止一个轮次是幂等的且不投递任何内容;未来 host 适配器需要具体的经认证交互,才能让该 seam 获得用户投递能力。 - **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 - **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 212536e713..ebac0a1296 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -103,6 +103,15 @@ export interface ContinuableStart { readonly messageId: MessageId } +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +export type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } + /** Options for following up with one continuable child. */ export interface SubagentFollowupOptions { /** Durable attribution retained on the delivered message; it grants no authority. */ @@ -412,6 +421,68 @@ export class SubagentContinuationManager { } } + /** + * Interrupt one live continuable child's current turn. Admission is + * synchronous and the effect is asynchronous: this authorizes the caller, + * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and + * returns without waiting for the target to observe the signal or reach + * quiescence. The Activation, its handle, accepted pending inbox work, and + * already-published descendants are untouched; the parked queue resumes only + * on a later waking send. + * + * An absent target is an accepted no-op, which uniformly covers natural + * completion races, repeated requests, one-shot ids, and unknown ids without + * consulting the durable catalog. A target whose disposal transaction is + * already open is likewise an accepted no-op after authorization. + * @param targetSessionId - the durable child session id to interrupt. + * @param authority - the human parent address or exact live ancestor Agent. + * @throws {SubagentError} `UNAUTHORIZED` when the presented authority does + * not own the live target: a stale or self-targeting ancestor caller, a + * parent address that is not the live target's durable direct parent, or + * an ancestor outside the target's recorded live lineage. + */ + interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void { + if (authority.kind === 'ancestor') { + const caller = authority.agent + // A stale caller is rejected even when the target is absent, so a + // replaced same-id Agent can never probe this manager's state. + if (this.ctx.agents.get(caller.id) !== caller) { + throw new SubagentError( + `interrupting "${targetSessionId}" requires the exact live ancestor agent`, + 'UNAUTHORIZED', + ) + } + if (caller.id === targetSessionId) { + throw new SubagentError( + `agent "${caller.id}" cannot interrupt itself`, + 'UNAUTHORIZED', + ) + } + } + const activation = this.activations.get(targetSessionId) + if (activation === undefined) return + if (authority.kind === 'user') { + if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) { + throw new SubagentError( + `subagent "${targetSessionId}" belongs to another parent session`, + 'UNAUTHORIZED', + ) + } + } else if (!activation.ancestry.has(authority.agent)) { + throw new SubagentError( + `subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`, + 'UNAUTHORIZED', + ) + } + // Disposal already stopped the target with a whole-Activation teardown; + // a second cancel would be a redundant signal on a closing handle. + if (activation.disposal !== undefined) return + activation.handle.agent.cancel( + authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' }, + { keepInbox: true }, + ) + } + /** * Deliver explicitly selected content from one resident continuable child to * its durable direct parent. Sender authorization, parent resolution, and diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5bcd53d6a5..e179bfd8b4 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -58,6 +58,7 @@ import type { ContinuableStart, ContinuableStartSpec, SubagentFollowupOptions, + SubagentInterruptAuthority, SubagentReportOptions, } from './continuation.ts' import SubagentActivationSetupRegistry from './activation-setup-registry.ts' @@ -111,6 +112,7 @@ export type { ContinuableStartSpec, CoordinatorMessageSource, SubagentFollowupOptions, + SubagentInterruptAuthority, SubagentReportDelivery, SubagentReportMessageSource, SubagentReportOptions, @@ -231,6 +233,24 @@ export class SubagentService extends Service { return this.requireContinuations().followup(parent, childId, content, options) } + /** + * Interrupt one live continuable child's current turn under a human parent + * address or an exact live ancestor Agent. Fire-and-return: the cancel + * signal is issued before this returns, but the target may keep running + * until it observes the signal. Pending inbox work, the Activation, and + * published descendants are preserved; only a later waking send resumes the + * parked FIFO queue. An absent target — including a one-shot or unknown id — + * is an accepted no-op, as is a manager-less composition, which cannot own a + * live Activation. + * @param targetSessionId - the durable child session id to interrupt. + * @param authority - the human parent address or exact live ancestor Agent. + * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the + * live target. + */ + interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void { + this.continuations?.interrupt(targetSessionId, authority) + } + /** * Deliver selected content from one live continuable child to its durable * direct parent. The child is the authority credential; callers cannot name a diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dca06add38..9370676f76 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1717,3 +1717,223 @@ describe('continuable errors', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) }) + +describe('SubagentService.interrupt', () => { + it('aborts the current turn durably, parks accepted follow-ups, and resumes them only on a waking send', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, + { chunks: textResponse('third') }, + { chunks: textResponse('fourth') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + await followup(ctx, parent, started.childId, message('parked B')) + await followup(ctx, parent, started.childId, message('parked C')) + const cancelSpy = vi.spyOn(child, 'cancel') + + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + + expect(cancelSpy).toHaveBeenCalledTimes(1) + expect(cancelSpy).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + // Cancellation is cooperative: the held model call observes it on release. + releaseFirst.resolve(undefined) + await child.whenIdle() + // Parked, not resumed: no second model request follows the abort, the + // accepted follow-ups stay pending, and the same Activation stays resident. + expect(adapter.requests).toHaveLength(1) + expect(child.inbox.nextTurn).toHaveLength(2) + expect(child.status).toBe('idle') + expect(ctx.agents.get(started.childId)).toBe(child) + + // Only an explicit waking send restores the driver; the parked items then + // run before it in the existing FIFO order. + await followup(ctx, parent, started.childId, message('waking D')) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D']) + const turnEnds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => (event).data.reason.kind) + expect(turnEnds).toEqual(['aborted', 'completed', 'completed', 'completed']) + }) + + it('interrupts only the target while its resident descendant keeps running', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const grandchildAgent = ctx.agents.get(grandchild.childId)! + const childCancel = vi.spyOn(child, 'cancel') + const grandchildCancel = vi.spyOn(grandchildAgent, 'cancel') + + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + + expect(childCancel).toHaveBeenCalledTimes(1) + releaseChild.resolve(undefined) + await child.whenIdle() + // The target parks as a waiting owner; the published descendant was never + // signalled and keeps its own turn open. + expect(grandchildCancel).not.toHaveBeenCalled() + expect(ctx.agents.get(started.childId)).toBe(child) + expect(ctx.agents.get(grandchild.childId)).toBe(grandchildAgent) + + releaseGrandchild.resolve(undefined) + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(grandchild.childId) + const turnEnds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => (event).data.reason.kind) + expect(turnEnds).toEqual(['completed']) + }) + + it('authorizes the human address against the live target\'s durable direct parent', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const cancelSpy = vi.spyOn(child, 'cancel') + + expect(() => { ctx.subagents.interrupt(started.childId, { + kind: 'user', + parentSessionId: SessionId('stranger'), + }) }).toThrow(/belongs to another parent session/) + expect(cancelSpy).not.toHaveBeenCalled() + + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + expect(cancelSpy).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + hold.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + + it('lets a deep exact live ancestor interrupt its descendant with the parent cause', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const grandchildAgent = ctx.agents.get(grandchild.childId)! + const childCancel = vi.spyOn(child, 'cancel') + const grandchildCancel = vi.spyOn(grandchildAgent, 'cancel') + + // Deep ancestor: the top-level parent interrupts the grandchild. + ctx.subagents.interrupt(grandchild.childId, { kind: 'ancestor', agent: parent }) + expect(grandchildCancel).toHaveBeenCalledWith({ kind: 'parent' }, { keepInbox: true }) + // Direct ancestor: the same authority kind covers the immediate parent. + ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) + expect(childCancel).toHaveBeenCalledWith({ kind: 'parent' }, { keepInbox: true }) + + releaseChild.resolve(undefined) + releaseGrandchild.resolve(undefined) + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + }) + + it('rejects self, sibling, stale, and unrelated ancestor callers without touching the target', async () => { + const releaseA = Promise.withResolvers() + const releaseB = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('a'), gate: releaseA.promise }, + { chunks: textResponse('b'), gate: releaseB.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const targetStart = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const target = ctx.agents.get(targetStart.childId)! + const siblingStart = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const sibling = ctx.agents.get(siblingStart.childId)! + const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + const stale = { ...parent, id: parent.id } as unknown as Agent + const cancelSpy = vi.spyOn(target, 'cancel') + + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: target }) }) + .toThrow(/cannot interrupt itself/) + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: sibling }) }) + .toThrow(/not a live descendant/) + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: stranger }) }) + .toThrow(/not a live descendant/) + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: stale }) }) + .toThrow(/exact live ancestor/) + // A stale caller is rejected before target lookup, even for an absent id. + expect(() => { ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: stale }) }) + .toThrow(/exact live ancestor/) + expect(cancelSpy).not.toHaveBeenCalled() + + releaseA.resolve(undefined) + releaseB.resolve(undefined) + await waitNoActivation(ctx, targetStart.childId) + await waitNoActivation(ctx, siblingStart.childId) + }) + + it('accepts absent and one-shot ids as no-ops without touching the one-shot Agent', async () => { + const { ctx, parent } = await setup([textResponse('one shot')]) + ctx.subagents.interrupt(SessionId('missing'), { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: parent }) + + const run = await ctx.subagents.start('spawn', { + label: 'one-shot work', + prompt: message('one-shot work'), + parent, + signal: testSignal, + }) + const oneShot = run.localAgent! + const cancelSpy = vi.spyOn(oneShot, 'cancel') + ctx.subagents.interrupt(run.id, { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(run.id, { kind: 'ancestor', agent: parent }) + expect(cancelSpy).not.toHaveBeenCalled() + await run.result + await run.dispose() + }) + + it('accepts an interrupt after natural completion', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) + }) + + it('accepts an interrupt that lost the race with disposal without signalling twice', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const cancelSpy = vi.spyOn(child, 'cancel') + + // Scoped teardown opens the disposal transaction synchronously and issues + // its own whole-Activation cancel before this call returns. + const drained = ctx.subagents.drainContinuableDescendants([parent]) + expect(cancelSpy).toHaveBeenCalledTimes(1) + + // Interrupt after the cutoff: accepted no-op, no second signal, no waiting. + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + expect(cancelSpy).toHaveBeenCalledTimes(1) + + hold.resolve(undefined) + await drained + }) +}) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 8b90719227..a50696cf2a 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -133,6 +133,16 @@ describe('SubagentService', () => { await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() }) + it('treats interrupt as an accepted no-op when no manager was bound', async () => { + const { subagents } = await service() + // Without a continuation manager no live Activation can exist, so there is + // nothing to stop and nothing to authorize against. + expect(() => { subagents.interrupt(SessionId('child'), { + kind: 'user', + parentSessionId: SessionId('parent-1'), + }) }).not.toThrow() + }) + it('rejects continuable operations when their runtime services are absent', async () => { const { subagents } = await service() await expect(subagents.startContinuable({ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 54581a8605..c8ddb487ef 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -173,6 +173,7 @@ export const LINK_MAP: Readonly> = { ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', SubagentFollowupOptions: 'subagent.md', + SubagentInterruptAuthority: 'subagent.md', SubagentListEntry: 'subagent.md', SubagentProvider: 'subagent.md', SubagentReportDelivery: 'subagent.md', From 82019134b1c3ff0cdcb7b4edf7d1f6a031b4ad7c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 12:09:09 +0800 Subject: [PATCH 020/262] test(web): verify subagent.interrupt over the real composition A browserless keyless-replay e2e boots the shipped web composition, holds a continuable child's turn open with a replay hang entry, queues a follow-up and interrupts over plain HTTP, and proves from the real session state that the turn aborted, the follow-up parked without a new turn, and a waking send resumed the preserved FIFO order. Refs #1535 --- apps/web/tests/subagent-interrupt.e2e.ts | 176 +++++++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 3 files changed, 178 insertions(+) create mode 100644 apps/web/tests/subagent-interrupt.e2e.ts diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts new file mode 100644 index 0000000000..e1e35e7981 --- /dev/null +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -0,0 +1,176 @@ +// Web e2e scenario (browserless): the subagent.interrupt RPC against the real +// composition. A live continuable child holds its model turn open through a +// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and +// proves from the real session state that the turn aborted, the follow-up +// parked without auto-starting a new turn, and a later waking send resumed the +// preserved FIFO order. No browser: the RPC surface is the product surface +// under test, and PR-stacked UI coverage owns the composer interaction. +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent' +import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts' + +const MODE = webSnapshotMode() +const INITIAL = 'Explain event sourcing in one sentence.' +const FOLLOWUP = 'Now give the same explanation to a human reader.' +const WAKING = 'And add one concrete example.' + +type RpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } + +/** POST one unary RPC through the real HTTP carrier and unwrap its result. */ +async function rpc(baseUrl: string, method: string, payload: unknown): Promise> { + const response = await fetch(`${baseUrl}/api/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`, + method, + payload, + }), + }) + if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + return (await response.json() as { result: RpcResult }).result +} + +/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** One text-only scripted model completion (no tool calls: real tools are mounted). */ +function textCompletion(text: string): object { + return { + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], + } +} + +describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => { + let scaffold: WebScaffold + let sidecarRoot: string + let readyFile: string + let parentId: SessionId + let childId: SessionId + + beforeAll(async () => { + sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-')) + readyFile = join(sidecarRoot, 'hang-ready') + // Whole-script replacement: the child's three model calls are the hang + // (turn 1, interrupted), the parked follow-up's turn, and the waking turn. + // The parent never runs a turn, so the child claims this primary script. + await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([ + { kind: 'hang', readyFile }, + textCompletion('resumed response one'), + textCompletion('resumed response two'), + ])) + // Header-only primary fixture: the bare-array override replaces the + // derived script entirely; the path only anchors replay installation. + await writeFile( + join(sidecarRoot, 'session.jsonl'), + '{"type":"session","version":0,"id":"primary","createdAt":0}\n', + ) + scaffold = await launchWebScaffold({ + replayFixture: join(sidecarRoot, 'session.jsonl'), + replayOverride: join(sidecarRoot, 'replay.override.json'), + }) + + // A live parent Agent through the real API; no workspace or browser. + const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', { + cwd: scaffold.workspaceCwd, + }) + if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`) + parentId = sessionId(created.value.sessionId) + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('created parent session did not publish a live Agent') + + const started = await scaffold.ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'event-sourcing researcher', + signal: new AbortController().signal, + request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + }) + childId = started.childId + // The hang entry writes readyFile after its prefix chunks, immediately + // before waiting for cancellation: the deterministic "turn is open" gate. + await waitFor(() => existsSync(readyFile), 'the held child turn to open') + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await scaffold?.close().catch((error: unknown) => failures.push(error)) + await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed') + }) + + it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => { + // Queue the follow-up while the turn is still open, then interrupt. + const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', { + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: FOLLOWUP }], + }) + expect(queued).toMatchObject({ ok: true }) + + const settled = scaffold.whenTurnSettled() + const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', { + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + }) + expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } }) + // accepted acknowledges the admitted cancel, not quiescence: wait for the + // aborted turn/end (the composition's first turn/end) before asserting. + expect(await settled).toBe(childId) + + // Parked, not resumed: the Activation stays resident with an idle driver, + // the follow-up is retained, and no second turn opened. + const child = scaffold.ctx.agents.get(childId) + expect(child).toBeDefined() + expect(child!.status).toBe('idle') + expect(child!.inbox.nextTurn).toHaveLength(1) + expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1) + expect((lastEnd)?.data.reason.kind).toBe('aborted') + + // Only an explicit waking send resumes the parked queue, FIFO, then the + // child runs both turns to completion and settles. + const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', { + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: WAKING }], + }) + expect(waking).toMatchObject({ ok: true }) + await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() + + const loaded = await scaffold.ctx.sessionPersistence.load(childId) + // Human-origin messages only: the real composition also injects + // runtime-context snapshots as non-user-source messages. + const userTexts = loaded.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'user' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) + expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING]) + const turnEndKinds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => (event).data.reason.kind) + expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) + }, 120_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7fb84f1507..3fa6ba8905 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -67,6 +67,7 @@ "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", + "tests/subagent-interrupt.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 81f660c317..1997f0ea47 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -54,6 +54,7 @@ "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", + "apps/web/tests/subagent-interrupt.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 57e9e6977c3065c461d3c5da38a2b841d6bb1ea1 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 12:50:44 +0800 Subject: [PATCH 021/262] docs(subagent): record the public interrupt in the continuation contract docs The continuable-subagent core-data page and the continuation lifecycle Agent Note claimed no public subagent cancellation existed; interrupt() is now that one public stop. Both pages and their Chinese counterparts state the shipped contract and link the owning interrupt note. Refs #1535 --- ...8-continuable-subagent-conversations.i18n.yaml | 4 ++-- ...26-07-28-continuable-subagent-conversations.md | 6 +++--- ...07-28-continuable-subagent-conversations.zh.md | 6 +++--- docs/core-data-structures/subagent.i18n.yaml | 4 ++-- docs/core-data-structures/subagent.md | 15 ++++++++++++++- docs/core-data-structures/subagent.zh.md | 15 ++++++++++++++- scripts/type-equiv.manifest.json | 5 +++++ 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 99629a74c0..82639d4aa8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: a5a900c3bb30a8d965aabc0bf498f8399ee70e7a -2026-07-28-continuable-subagent-conversations.zh.md: bde8e874ba27950dc0fab8449c6ba2ee49737d41 +2026-07-28-continuable-subagent-conversations.md: 56d9abb2b09577b2fba14b9a655a941417e0c493 +2026-07-28-continuable-subagent-conversations.zh.md: 77f497ceeef35dcc519242dca30c81759cc3b907 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index a5a900c3bb..56d9abb2b0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -131,7 +131,7 @@ Parent-originated delivery requires the parent to be live when admitted and keep ### Durability, disposal, and recovery -Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service. +Without Tasks there is no `task_output`, `task_kill`, Task status, or per-message result promise. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the accepted message or dispose the Activation through `ctx.subagents`; the only public stop is the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md), which cancels the live target's current turn with `keepInbox` and leaves residency, pending work, and descendants intact. Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions. @@ -145,7 +145,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior. -It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, public residency query, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability. +It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, new live-Activation or descendant limit, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability. ## Alternatives considered @@ -186,7 +186,7 @@ The implementation pins these behaviors: - `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; durable message provenance cannot authorize delivery. - Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn. - `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. -- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup. +- Caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup; the [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) is the one public stop and does not enter teardown. - This version exposes no subagent steering operation or current-turn controller state. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index bde8e874ba..77f497ceee 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -131,7 +131,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect ### 持久性、dispose 与恢复 -没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 +没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态或逐消息结果 promise。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消已接受的消息或 dispose 激活;唯一的公开停止操作是后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md),它以 `keepInbox` 取消在线目标的当前轮次,驻留、待处理工作与后代均保持不变。 宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。 @@ -145,7 +145,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 -它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。 +它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。 ## 曾考虑的替代方案 @@ -186,7 +186,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 -- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理。 +- 调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理;[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)是唯一的公开停止操作,且不进入拆卸流程。 - 本版本不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index eb708f33da..8c3aa46364 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/core-data-structures/subagent.md -subagent.md: 4d7552eb5749284d90e31e62d8ac02e3d7a21b1d -subagent.zh.md: ebcb1ce445c164352109d6613028c7a36c10d6d4 +subagent.md: fba934233fb7e890d6a68bd4cc7d37f90ab913ed +subagent.zh.md: 70651397f82e2cf4c0fe18aa26ff8247484ca0aa diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 4d7552eb57..fba934233f 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -139,7 +139,20 @@ The Agent inbox is the only queue. Every continuation message becomes one `Agent Follow-up authority comes from an exact live Agent tool context. The authenticated Agent must be the durable child's direct parent recorded in `SessionHeader.parentSession`. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority; the optional model-facing tool uses `CoordinatorMessageSource`. -For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation. +For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no steering operation. + +`SubagentService.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its pending inbox work, and published descendants are untouched; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. + +```ts type-equiv +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } +``` Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index ebcb1ce445..70651397f8 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -139,7 +139,20 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 后续操作的权限来自确切的在线 Agent 工具上下文。已认证的 Agent 必须是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限;可选的面向模型工具使用 `CoordinatorMessageSource`。 -对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。 +对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 steering(中途引导)操作。 + +`SubagentService.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待静止即返回。Activation、其待处理的 inbox 工作与已发布的后代均不受影响;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 + +```ts type-equiv +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } +``` 每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 095fa25b4f..5c2f3eb454 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1150,6 +1150,11 @@ "symbol": "SubagentFollowupOptions", "source": "packages/subagent/subagent/src/continuation.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentInterruptAuthority", + "source": "packages/subagent/subagent/src/continuation.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "ContinuableStart", From 336c84baf0ead4e5fefbd7d40725de982e05c1ae Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 7 Aug 2026 10:27:43 +0800 Subject: [PATCH 022/262] docs(subagent): clarify interrupt edge cases --- .../2026-08-06-continuable-subagent-interrupt.i18n.yaml | 4 ++-- .../feature/2026-08-06-continuable-subagent-interrupt.md | 8 ++++++-- .../2026-08-06-continuable-subagent-interrupt.zh.md | 8 ++++++-- docs/cordis-catalog/services.md | 7 ++++--- docs/core-data-structures/subagent.i18n.yaml | 4 ++-- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/event-producer-consumer.md | 8 ++++---- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/src/api/subagents.ts | 4 ++-- packages/subagent/subagent/README.i18n.yaml | 4 ++-- packages/subagent/subagent/README.md | 3 ++- packages/subagent/subagent/README.zh.md | 3 ++- packages/subagent/subagent/src/continuation.ts | 7 ++++--- packages/subagent/subagent/src/index.ts | 7 ++++--- 15 files changed, 43 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index fe6e8870f5..643bb9f9ac 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.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-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: 729f1eb8259aa28aa771ed71872ebce9cadd4ed8 -2026-08-06-continuable-subagent-interrupt.zh.md: 15fe34d8b5e1d67623aabfe08710f341739e92e1 +2026-08-06-continuable-subagent-interrupt.md: 7bbc6ecf6736e9e34924113be984a3bf7436b9b9 +2026-08-06-continuable-subagent-interrupt.zh.md: 22a71bf4cd3d9b949ae8cf458a3c83b65739d396 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index 729f1eb825..7bbc6ecf67 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -10,7 +10,7 @@ A running continuable subagent could not be stopped without destroying it. The c ## Decision -`ctx.subagents.interrupt(targetSessionId, authority)` stops only the live target's current turn. The manager primitive authorizes synchronously, calls the existing `Agent.cancel(cause, { keepInbox: true })`, and returns `void` — fire-and-return: the cancel signal is guaranteed issued, target quiescence is not awaited. Nothing else changes: no Activation disposal, no handle release, no descendant cascade, no inbox clearing, and no `AgentLoop` or `CancelOptions` change. Because `keepInbox` parks the pending queue at idle, an interrupt never auto-starts the next queued follow-up; only a later explicit waking send resumes the preserved FIFO order. +`ctx.subagents.interrupt(targetSessionId, authority)` stops only the live target's current turn. The manager primitive authorizes synchronously, calls the existing `Agent.cancel(cause, { keepInbox: true })`, and returns `void` — fire-and-return: the cancel signal is guaranteed issued, target quiescence is not awaited. Nothing else changes: no Activation disposal, no handle release, no descendant cascade, no inbox clearing, and no `AgentLoop` or `CancelOptions` change. Because `keepInbox` parks the unclaimed pending queue at idle, an interrupt never auto-starts the next queued follow-up; work already claimed into the interrupted turn belongs to that turn and is not requeued. Once the interrupted driver is idle, an explicit waking send resumes the preserved FIFO order. Authority is a closed two-variant union, deliberately wider than delivery authority because stopping a turn is idempotent and delivers no content: @@ -35,7 +35,11 @@ The Host RPC `subagent.interrupt` takes the continuable `SubagentAddress` and re ## Consequences -A human or ancestor can now stop a runaway continuable turn without losing the child, its queued work, or its running descendants; the cost is a deliberately weak postcondition (`accepted` means "signal issued", so a target may remain visibly `running` until it observes the signal) that clients must render honestly. The parked-queue rule means an interrupted child sits idle with retained work until someone sends a waking message — an intentional human-in-the-loop pause, not a scheduler defect. The Web Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. +A human or ancestor can stop a runaway continuable turn without losing the child, its unclaimed queued work, or its running descendants; the cost is a deliberately weak postcondition (`accepted` means "signal issued", so a target may remain visibly `running` until it observes the signal) that clients must render honestly. The parked-queue rule means an interrupted child sits idle with retained work until a waking message arrives after the driver is idle — an intentional human-in-the-loop pause, not a scheduler defect. A waking send accepted during abort convergence currently remains queued without latching wake; Issue #1838 tracks the shared agent-loop correction. + +The address-only RPC exposes one bit of live residency: an absent target is accepted while a live target under a mismatched parent returns `subagent-unauthorized`. The single-user local Host trust model accepts that observability; a future multi-principal Host must revisit both authority and response indistinguishability. + +The Web Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index 15fe34d8b5..22a71bf4cd 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`ctx.subagents.interrupt(targetSessionId, authority)` 只停止在线目标的当前轮次。管理器原语同步完成鉴权,调用现有的 `Agent.cancel(cause, { keepInbox: true })`,然后返回 `void`——fire-and-return:保证取消信号已发出,但不等待目标静止。其余一切不变:不 dispose Activation、不释放 handle、不级联后代、不清空 inbox,也不改动 `AgentLoop` 或 `CancelOptions`。由于 `keepInbox` 让待处理队列停在 idle,中断绝不会自动启动下一个排队的 follow-up;只有之后一次显式唤醒发送才按保留的 FIFO 顺序恢复。 +`ctx.subagents.interrupt(targetSessionId, authority)` 只停止在线目标的当前轮次。管理器原语同步完成鉴权,调用现有的 `Agent.cancel(cause, { keepInbox: true })`,然后返回 `void`——fire-and-return:保证取消信号已发出,但不等待目标静止。其余一切不变:不 dispose Activation、不释放 handle、不级联后代、不清空 inbox,也不改动 `AgentLoop` 或 `CancelOptions`。由于 `keepInbox` 让尚未领取的待处理队列停在 idle,中断绝不会自动启动下一个排队的 follow-up;已被领取进入中断轮次的工作属于该轮次,不会重新入队。被中断的 driver 进入 idle 后,一次显式唤醒发送会按保留的 FIFO 顺序恢复。 授权是一个封闭的双变体 union,刻意比投递权限更宽,因为停止一个轮次是幂等的且不投递任何内容: @@ -35,7 +35,11 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 ## Consequences -人类或 ancestor 现在可以停止一个失控的 continuable 轮次,而不丢失 child、其排队工作或正在运行的后代;代价是一个刻意保持弱的后置条件(`accepted` 表示"信号已发出",目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle,直到有人发送唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 +人类或 ancestor 可以停止一个失控的 continuable 轮次,而不丢失 child、其尚未领取的排队工作或正在运行的后代;代价是一个刻意保持弱的后置条件(`accepted` 表示“信号已发出”,目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle,直到 driver 进入 idle 后收到唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。在 abort 收敛期间被接受的唤醒发送目前会保持排队而不锁存 wake;Issue #1838 跟踪共享的 agent-loop 修正。 + +仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。 + +Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 ## Testing diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index caf081b5a6..59a9cf527d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2075,9 +2075,10 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti * Interrupt one live continuable child's current turn under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running - * until it observes the signal. Pending inbox work, the Activation, and - * published descendants are preserved; only a later waking send resumes the - * parked FIFO queue. An absent target — including a one-shot or unknown id — + * until it observes the signal. Unclaimed pending inbox work, the Activation, + * and published descendants are preserved; claimed work is not requeued. + * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * queue. An absent target — including a one-shot or unknown id — * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 8c3aa46364..15d5e19578 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/core-data-structures/subagent.md -subagent.md: fba934233fb7e890d6a68bd4cc7d37f90ab913ed -subagent.zh.md: 70651397f82e2cf4c0fe18aa26ff8247484ca0aa +subagent.md: 4de77c6232d73d4b4e9dd87e9afc7a077e61f1a0 +subagent.zh.md: 3310bb4bf8cad1314ec26eef9042955959aba5fe diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index fba934233f..4de77c6232 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -141,7 +141,7 @@ Follow-up authority comes from an exact live Agent tool context. The authenticat For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no steering operation. -`SubagentService.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its pending inbox work, and published descendants are untouched; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. +`SubagentService.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. ```ts type-equiv /** diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 70651397f8..3310bb4bf8 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -141,7 +141,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 steering(中途引导)操作。 -`SubagentService.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待静止即返回。Activation、其待处理的 inbox 工作与已发布的后代均不受影响;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 +`SubagentService.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待静止即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;过期的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 ```ts type-equiv /** diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 940d89eae0..acd2bc5e91 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:283`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../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:140`](../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:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `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/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/telemetry/session-telemetry/src/index.ts:43`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index acaefa5d5f..1fd08a5c95 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -930,7 +930,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', - jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Pending inbox work, the Activation, and\n * published descendants are preserved; only a later waking send resumes the\n * parked FIFO queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */', + jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Unclaimed pending inbox work, the Activation,\n * and published descendants are preserved; claimed work is not requeued.\n * Once the interrupted driver is idle, a waking send resumes the parked FIFO\n * queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */', }, { signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise', diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 8efb452c2e..a85f4bc750 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -105,8 +105,8 @@ export interface SubagentsApi { * durable direct-parent authority, without requiring a live parent Agent, * consulting the catalog, or resuming anything. Fire-and-return: `accepted` * acknowledges the admitted cancel signal, not target quiescence, so the - * child may remain visibly running briefly. Queued follow-ups are kept and - * parked; an absent, idle, or already-completed target is likewise + * child may remain visibly running briefly. Unclaimed queued follow-ups are + * kept and parked; an absent, idle, or already-completed target is likewise * `accepted`. */ interrupt( diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 250fd1a078..9b9843af87 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 7de3a5563b274e925fba931a6d5de17e68cc397c -README.zh.md: 6067555544ec0c32729beb2b4e3f773e31b747a1 +README.md: eb1baa39231a2c058b3ada1a23b7b87e2bb2e384 +README.zh.md: 8402df3e4ff2559898dfd4dd512ab1601c9ec61d diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 7de3a5563b..eb1baa3923 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,7 +18,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | -| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Pending inbox work, the Activation, and published descendants are preserved; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. | +| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. | | `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | @@ -102,6 +102,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. - **No host-user continuation** — `followup()` requires the exact live direct parent. Only `interrupt()` accepts a durable parent-address user authority, because stopping a turn is idempotent and delivers no content; a future host adapter needs a concrete authenticated interaction before the seam gains a user delivery capability. - **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn. +- **Wake gap during cancellation convergence** — a waking follow-up accepted after the interrupt signal is issued but before the active driver becomes idle remains queued until another waking send. Issue #1838 owns the agent-loop wake latch, which also affects ordinary session cancellation. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. - **No durable report mailbox** — reports require a live direct parent and provide acceptance identity rather than exactly-once delivery or a read receipt. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6067555544..8402df3e4f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -18,7 +18,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent(`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入是同步的、生效是异步的:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。待处理的 inbox 工作、Activation 与已发布的后代均保持不变;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 | +| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent(`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入是同步的、生效是异步的:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。尚未领取的待处理 inbox 工作、Activation 与已发布的后代均保持不变;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;过期的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 | | `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | @@ -102,6 +102,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 - **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。只有 `interrupt()` 接受持久化 parent 地址形式的用户授权,因为停止一个轮次是幂等的且不投递任何内容;未来 host 适配器需要具体的经认证交互,才能让该 seam 获得用户投递能力。 - **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。 +- **取消收敛期间存在唤醒缺口**:中断信号发出后、活动 driver 进入 idle 前被接受的唤醒型 follow-up 会保持排队,直到另一条唤醒发送到达。Issue #1838 负责 agent-loop 的唤醒锁存;普通会话取消也受此影响。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 - **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 - **没有持久化的上报 mailbox**:上报需要实时直接父级,提供的是接受标识,不保证恰好一次投递,也不提供已读回执。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index ebac0a1296..67cc177fe1 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -426,9 +426,10 @@ export class SubagentContinuationManager { * synchronous and the effect is asynchronous: this authorizes the caller, * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and * returns without waiting for the target to observe the signal or reach - * quiescence. The Activation, its handle, accepted pending inbox work, and - * already-published descendants are untouched; the parked queue resumes only - * on a later waking send. + * quiescence. The Activation, its handle, accepted unclaimed inbox work, and + * already-published descendants are untouched; work already claimed into the + * interrupted turn is not requeued. Once the interrupted driver is idle, a + * waking send resumes the parked queue. * * An absent target is an accepted no-op, which uniformly covers natural * completion races, repeated requests, one-shot ids, and unknown ids without diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index e179bfd8b4..8bf4efb5d0 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -237,9 +237,10 @@ export class SubagentService extends Service { * Interrupt one live continuable child's current turn under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running - * until it observes the signal. Pending inbox work, the Activation, and - * published descendants are preserved; only a later waking send resumes the - * parked FIFO queue. An absent target — including a one-shot or unknown id — + * until it observes the signal. Unclaimed pending inbox work, the Activation, + * and published descendants are preserved; claimed work is not requeued. + * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * queue. An absent target — including a one-shot or unknown id — * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. From 5793614b866de1e7af82a9627728d8b733da6c1b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 12:45:37 +0800 Subject: [PATCH 023/262] feat(web): expose continuable subagent interrupt The composer's single primary action now stops a running continuable child: Session.cancel() routes a continuable address through subagent.interrupt (ordinary sessions keep session.cancel; one-shot addresses stay uncancellable), and InputBar switches the same Send button to Stop while a continuable child runs. A running parent-offline child keeps the default composer with disabled input so that Stop stays reachable; the read-only takeover returns once it stops. Refs #1535 --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 12 +- ...026-07-27-web-subagent-conversations.zh.md | 12 +- ...6-continuable-subagent-interrupt.i18n.yaml | 4 +- ...26-08-06-continuable-subagent-interrupt.md | 4 +- ...08-06-continuable-subagent-interrupt.zh.md | 4 +- .../offline-composer.expected.md | 25 ++ apps/web/tests/subagent-interrupt-ui.e2e.ts | 271 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../runtime/src/client/sessions/session.ts | 15 +- packages/client/runtime/tests/session.spec.ts | 30 +- .../ui-conversation/src/client/locales.ts | 2 + .../src/client/skeleton/InputBar.tsx | 25 +- .../ui-conversation/tests/input-bar.spec.tsx | 46 ++- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- .../client/ui-subagent/src/client/index.ts | 6 +- .../ui-subagent/tests/browser-plugin.spec.ts | 9 +- tsconfig.host.json | 1 + 20 files changed, 433 insertions(+), 50 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md create mode 100644 apps/web/tests/subagent-interrupt-ui.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 56952f8b69..4ec9a77d72 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: edaaa97bbfcf0ce1751db84d8fc2d3ab07af8b03 -2026-07-27-web-subagent-conversations.zh.md: 4c5e670cc46621b889d8463daa90afbf43bde30f +2026-07-27-web-subagent-conversations.md: da4de7d4bcd23f61459eba3fee8108b0af1c95f9 +2026-07-27-web-subagent-conversations.zh.md: 43d9710ec2b63a09bd3db029f172746d63d91cfe diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index edaaa97bbf..da4de7d4bc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -20,7 +20,7 @@ Every opened child carries a catalog-derived address `{ parentSessionId, childSe The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization. -The ordinary Stop action is absent from addressed child conversations. `SubagentService.followup()` owns admission only until inbox acceptance and intentionally exposes no public child cancellation operation. A later cancellation design needs an explicit authority and lifecycle contract rather than falling through to `session.cancel`. +Stopping an addressed child never falls through to `session.cancel`. `SubagentService.followup()` owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web. This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md). @@ -45,7 +45,7 @@ Healthy rows reuse the standard session projections retained in the list mirror. Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. -A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. +A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input disabled so the same primary Stop stays reachable, and the read-only takeover returns once it stops. With a live parent, Enter admits another FIFO turn even while the child runs, and the single primary action follows the ordinary running convention: Send while idle, Stop while running, routed through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior. Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation seam. @@ -89,7 +89,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence **Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle. -**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and the continuation seam exposes no authority-safe cancellation handle. +**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and, at this decision's time, the continuation seam exposed no authority-safe cancellation handle. The later [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md) added that explicit authority as a dedicated subagent route; falling through to `session.cancel` remains rejected. **Show only continuable children.** Rejected because the durable catalog deliberately describes both session-backed modes. One-shot transcripts remain useful even though they never accept follow-ups. @@ -103,9 +103,9 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. -- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. +- Client object tests pin retained and restored addresses, one-shot read-only and cancel rejection, history routing, continuable prompt and interrupt routing, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. - jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and adaptive long-duration presentation, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and the aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences @@ -114,4 +114,4 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. -- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency. +- Beyond the current-turn Stop of a running continuable child ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)), the UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 4c5e670cc4..43d9710ec2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -20,7 +20,7 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。 -已寻址 child 对话不提供普通 Stop 操作。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,并有意不公开任何 child 取消操作。后续取消设计需要显式的授权与生命周期契约,而不能回退到 `session.cancel`。 +停止一个已寻址 child 绝不回退到 `session.cancel`。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md),该契约会暂停而非丢弃待处理工作。one-shot child 在 Web 端仍不可取消。 本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。 @@ -45,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 -one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 +one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,只禁用其输入区,让同一个 primary Stop 保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 也会准入另一个 FIFO 轮次,而唯一的 primary 操作遵循普通运行约定:空闲时为 Send,运行时为 Stop,经由 `subagent.interrupt` 路由([中断契约](2026-08-06-continuable-subagent-interrupt.md))。提示词失败会通过普通错误行为保留草稿。 已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行 seam 之外激活持久化 child 历史。 @@ -89,7 +89,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 **自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。 -**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,而继续执行 seam 不会公开具备安全授权的取消句柄。 +**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,且在本决定当时,继续执行 seam 未公开具备安全授权的取消句柄。后来的[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md)以专用 subagent 路由补上了这项显式授权;回退到 `session.cancel` 仍被拒绝。 **只显示可继续 child。** 不予采纳,因为持久化目录有意描述由会话支撑的两种 mode。one-shot transcript 即使绝不接受后续消息,仍然有用。 @@ -103,9 +103,9 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 -- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 +- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 - jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行及自适应长耗时呈现,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 @@ -114,4 +114,4 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 -- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。 +- 除对正在运行的可继续 child 的当前轮次 Stop([中断契约](2026-08-06-continuable-subagent-interrupt.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index 643bb9f9ac..5cf5601fc0 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.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-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: 7bbc6ecf6736e9e34924113be984a3bf7436b9b9 -2026-08-06-continuable-subagent-interrupt.zh.md: 22a71bf4cd3d9b949ae8cf458a3c83b65739d396 +2026-08-06-continuable-subagent-interrupt.md: 1792d4c6d25858c861be0ff920206d0d4e684f41 +2026-08-06-continuable-subagent-interrupt.zh.md: ff9887481d1ded2743c7c8f9f162413fb6581d70 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index 7bbc6ecf67..1792d4c6d2 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -39,8 +39,8 @@ A human or ancestor can stop a runaway continuable turn without losing the child The address-only RPC exposes one bit of live residency: an absent target is accepted while a live target under a mismatched parent returns `subagent-unauthorized`. The single-user local Host trust model accepts that observability; a future multi-principal Host must revisit both authority and response indistinguishability. -The Web Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. +The Web surface reuses the existing primary Send/Stop toggle rather than adding a second action: the client `Session.cancel()` routes a continuable address through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep `session.cancel`), and a running parent-offline continuable child keeps the default composer with disabled input so that same primary Stop remains reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract). The model-facing `interrupt_agent` tool builds on this primitive in the stacked follow-up PR for issue #1535. ## Testing -Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. +Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar Send/Stop toggle with the parent-offline locked-input state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold a real child turn open with a replay hang entry and prove the interrupt transport, the aborted `turn/end`, the parked follow-up, and the FIFO resume end to end. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index 22a71bf4cd..ff9887481d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -39,8 +39,8 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。 -Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 +Web 侧复用现有的 primary Send/Stop 切换而不新增第二个操作:客户端 `Session.cancel()` 将 continuable 地址路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍走 `session.cancel`);parent 离线但仍在运行的 continuable child 保留默认 composer 并禁用其输入区,让同一个 primary Stop 保持可达,停止后恢复只读替代(周边目录与 composer 契约由 [Web subagent 对话](2026-07-27-web-subagent-conversations.md)拥有)。面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 ## Testing -`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。 +`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的 Send/Stop 切换及 parent 离线时锁定输入的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)用 replay hang 条目保持真实 child 轮次打开,端到端证明中断传输、中止的 `turn/end`、follow-up 暂停以及 FIFO 恢复。 diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md new file mode 100644 index 0000000000..6ea7b83bff --- /dev/null +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -0,0 +1,25 @@ +- banner: + - navigation "Session hierarchy": + - button "Ask a research subagent to" + - text: / + - button "event-sourcing researcher" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Explain event sourcing in one sentence. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- status: Deep diving... +- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] +- button "Commands" [disabled]: + - img +- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write +- button "Stop generating" diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts new file mode 100644 index 0000000000..be72f6546b --- /dev/null +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -0,0 +1,271 @@ +// Web e2e scenario: the composer's primary action interrupts a running +// continuable child. The child holds its model turn open through a replay +// hang entry; the browser proves the single primary Send/Stop toggle, the +// parent-offline disabled-input-with-Stop composer, the subagent.interrupt +// (never session.cancel) transport, the parked follow-up, and the FIFO resume +// on a waking send. +// +// Replay-binding note: only the PRIMARY script can hang, and scripts bind by +// first-call order, so the child issues the composition's first model call +// (claiming the overridden primary) and the parent's one UI prompt — needed +// so the non-blank parent renders its header catalog — binds to a derived +// child fixture afterwards. +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url)) +const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md') +const MODE = webSnapshotMode() +const LABEL = 'event-sourcing researcher' +const INITIAL = 'Explain event sourcing in one sentence.' +const FOLLOWUP = 'Now give the same explanation to a human reader.' +const WAKING = 'And add one concrete example.' +const PARKED_ANSWER = 'parked follow-up answer' +const WAKING_ANSWER = 'waking answer' + +/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** One text-only scripted model completion (no tool calls: real tools are mounted). */ +function textCompletion(text: string): object { + return { + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], + } +} + +describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let sidecarRoot: string + let childId: SessionId + let tripwire: ReturnType + const apiCalls: string[] = [] + + beforeAll(async () => { + sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-')) + const readyFile = join(sidecarRoot, 'hang-ready') + // The child claims this whole-script replacement: held turn 1, then the + // parked follow-up and waking turns. + await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([ + { kind: 'hang', readyFile }, + textCompletion(PARKED_ANSWER), + textCompletion(WAKING_ANSWER), + ])) + await writeFile( + join(sidecarRoot, 'session.jsonl'), + '{"type":"session","version":0,"id":"primary","createdAt":0}\n', + ) + // The parent's one prompted turn replays this recorded single text-only + // call (binding is positional, not lineage-aware). + const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl') + const base = await readFile(BASE_FIXTURE, 'utf8') + const [header, ...events] = base.trimEnd().split('\n') + if (header === undefined) throw new Error('base replay fixture has no header') + await writeFile(parentTurnPath, [ + header + .replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"') + .replace(/"createdAt":\d+/, '"createdAt":1784998084442'), + ...events, + '', + ].join('\n')) + scaffold = await launchWebScaffold({ + replayFixture: join(sidecarRoot, 'session.jsonl'), + replayOverride: join(sidecarRoot, 'replay.override.json'), + replayChildFixtures: [parentTurnPath], + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + page.on('request', (request) => { + const path = new URL(request.url()).pathname + if (path.startsWith('/api/')) apiCalls.push(path) + }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + + const parent = scaffold.ctx.agents.roots()[0] + if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') + // The child's first model call claims the primary override and holds. + const started = await scaffold.ctx.subagents.startContinuable({ + provider: 'spawn', + label: LABEL, + signal: new AbortController().signal, + request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + }) + childId = started.childId + await waitFor(() => existsSync(readyFile), 'the held child turn to open') + + // One prompted parent turn makes the parent non-blank so the session + // header (and its subagent catalog action) renders. + const parentSettled = scaffold.whenTurnSettled() + const parentInput = page.locator('textarea:enabled').first() + await parentInput.fill('Ask a research subagent to explain event sourcing.') + await parentInput.press('Enter') + expect(await parentSettled).toBe(parent.id) + + // Reload onto the restart baseline (the proven route to a freshly + // discovered catalog), with the child still live and running host-side. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(scaffold.ctx.agents.get(childId)?.status).toBe('running') + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (sidecarRoot !== undefined) { + await rm(sidecarRoot, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed') + }) + + it('locks input but keeps the same primary Stop when the parent is offline', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline')) + // Simulate a parent that went offline: the catalog delivers + // parentAvailable: false while the child Activation stays live (the + // interrupt RPC itself needs no live parent — PR 1's host coverage). + const pattern = '**/api/subagent.list' + await page.route(pattern, async (route) => { + const response = await route.fetch() + const body = await response.json() as { + result: { ok: true; value: { parentAvailable: boolean } } | { ok: false } + } + if (body.result.ok) body.result.value.parentAvailable = false + await route.fulfill({ response, json: body }) + }) + try { + await page.getByRole('button', { name: /1 subagent/ }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + const input = page.getByRole('textbox', { + name: 'Parent session offline; sending is unavailable but you can still stop the run', + }) + await input.waitFor({ timeout: 15_000 }) + expect(await input.isDisabled()).toBe(true) + // Still exactly one primary action, and it is an enabled Stop. + const stop = page.getByRole('button', { name: 'Stop generating' }) + expect(await stop.count()).toBe(1) + expect(await stop.isEnabled()).toBe(true) + expect(await page.getByRole('button', { name: 'Send message' }).count()).toBe(0) + await compareOrRefreshGolden( + OFFLINE_COMPOSER_EXPECTED, + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), + MODE, + ) + } finally { + await page.unroute(pattern) + } + }, 60_000) + + it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow')) + // Reselect the child with the truthful catalog: parent available again. + await page.getByRole('navigation', { name: 'Session hierarchy' }) + .getByRole('button').first().click() + await page.getByRole('button', { name: /1 subagent/ }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + const input = page.getByRole('textbox', { name: 'Message the agent' }) + await input.waitFor({ timeout: 15_000 }) + expect(await input.isDisabled()).toBe(false) + + // Queue a follow-up while the turn is open; the primary stays Stop. + const promptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagent.prompt') + await input.fill(FOLLOWUP) + await input.press('Enter') + expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result) + .toMatchObject({ ok: true }) + + const aborted = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off() + reject(new Error('interrupt did not reach an aborted turn/end')) + }, 30_000) + const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (session.id !== childId || event.type !== 'turn/end') return + clearTimeout(timer) + off() + if (event.data.reason.kind === 'aborted') resolve() + else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`)) + }) + }) + const stop = page.getByRole('button', { name: 'Stop generating' }) + expect(await stop.count()).toBe(1) + const interruptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagent.interrupt') + await stop.click() + expect(((await (await interruptResponse).json()) as { + result: { ok: boolean; value?: { accepted: boolean } } + }).result).toMatchObject({ ok: true, value: { accepted: true } }) + // The addressed child stops through its own RPC, never the generic one. + expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + await aborted + + // Parked: the Activation stays resident and idle with the retained + // follow-up; the primary returns to Send without a new turn starting. + await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') + const child = scaffold.ctx.agents.get(childId) + expect(child).toBeDefined() + expect(child!.inbox.nextTurn).toHaveLength(1) + expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) + + // Only the waking send resumes the parked queue, FIFO, to settlement. + await input.fill(WAKING) + await input.press('Enter') + await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) + await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) + await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() + + const loaded = await scaffold.ctx.sessionPersistence.load(childId) + const userTexts = loaded.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'user' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) + expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING]) + const turnEndKinds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => event.data.reason.kind) + expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3fa6ba8905..606f257e56 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -68,6 +68,7 @@ "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/subagent-interrupt.e2e.ts", + "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 075fd686ea..659afcac4a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -281,17 +281,22 @@ export class Session implements SessionFace { /** * Stop the active turn while the Host preserves pending inbox work; failures - * land in promptError (same error-strip display slot). + * land in promptError (same error-strip display slot). A continuable + * subagent address routes through `subagent.interrupt`, whose durable + * parent-address authority works without a live parent Agent; a one-shot + * address stays uncancellable (the UI offers no stop action, so this arm is + * defensive). * @returns the cancel result. */ async cancel(): Promise> { - if (this.address !== undefined) { + const address = this.address + if (address !== undefined && address.mode === 'one-shot') { const result: RpcResult<{ accepted: true }> = { ok: false, error: { code: 'subagent-delivery-unavailable', message: 'subagent activation cancellation is unavailable', - details: { childSessionId: this.address.childSessionId }, + details: { childSessionId: address.childSessionId }, }, } this.promptError = { op: 'stop', error: result.error } @@ -300,7 +305,9 @@ export class Session implements SessionFace { } let result: RpcResult<{ accepted: true }> try { - result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result + result = address !== undefined + ? (await this.api.subagents.interrupt(address)).result + : (await this.api.sessions.cancel({ sessionId: this.sessionId })).result } catch (error) { result = transportError(error) } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b5400ecff9..5d5f292f92 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -639,7 +639,7 @@ describe('paging', () => { }) describe('prompt and cancel errors', () => { - it('routes an addressed child through non-activating history and continuation prompt only', async () => { + it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { const api = new FakeApiClient() const session = new Session(SID, api, { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, @@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => { const cancelled = await session.cancel() expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) + expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) expect(api.callsOf('subagent.history')).toEqual([ { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 }, ]) @@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => { content: [{ type: 'text', text: '继续' }], }, ]) + expect(api.callsOf('subagent.interrupt')).toEqual([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) + // A successful interrupt leaves no stop error behind. + expect(session.getSnapshot().promptError).toBeNull() expect(session.getSnapshot().subagent).toEqual({ address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) }) + it('lands an interrupt business failure in promptError with op=stop', async () => { + const api = new FakeApiClient() + api.onSubagentInterrupt = () => Promise.resolve(err({ + code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, + }) as never) + const session = new Session(SID, api, { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const cancelled = await session.cancel() + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) + expect(session.getSnapshot().promptError).toMatchObject({ + op: 'stop', error: { code: 'subagent-unauthorized' }, + }) + }) + it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { const api = new FakeApiClient() const session = new Session(SID, api, { @@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => { }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + const cancelled = await session.cancel() expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) expect(api.callsOf('subagent.history')).toEqual([ { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 }, ]) expect(api.callsOf('subagent.prompt')).toEqual([]) + expect(api.callsOf('subagent.interrupt')).toEqual([]) + expect(api.callsOf('session.cancel')).toEqual([]) }) it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 11e852a8b6..a67b816012 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -17,6 +17,7 @@ export const zh = { 'placeholder.plan': PLAN_NEXT_ACTION_ZH, 'placeholder.default': '给智能体发消息', 'placeholder.unavailable': '会话不可用', + 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容', 'placeholder.workspace': '选择一个工作区开始', 'input.commands': '命令', @@ -159,6 +160,7 @@ export const en = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Message the agent', 'placeholder.unavailable': 'Session unavailable', + 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build', 'placeholder.workspace': 'Choose a workspace to start', 'input.commands': 'Commands', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7b24c09684..21abce0e38 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -83,11 +83,15 @@ export function InputBar({ // (undefined = capability absent → the chip renders nothing). const permissions = useProjection('permissions') + // A continuable child without its live parent cannot accept human input, + // but its primary Stop below stays available while it runs. + const continuable = subagent?.address.mode === 'continuable' + const parentOffline = continuable && !subagent.parentAvailable // Queue cut 1: running input stays free; locked = session removed, the - // inert no-workspace state, or the machine faces absent (no session). The - // transient machine locks (adjudicating pending / submitting) render - // read-only — the draft stays visible and focused, keystrokes drop. - const disabled = removed || inert || !live || blocked !== undefined + // inert no-workspace state, the machine faces absent (no session), or a + // parent-offline continuable child. An owner block also disables input; + // adjudicating and submitting render read-only so the draft stays visible. + const disabled = removed || inert || !live || blocked !== undefined || parentOffline const locked = disabled // The model seat is the ONE control a block leaves live: every block this // contract has is cleared by choosing a model, so locking it too would leave @@ -350,8 +354,9 @@ export function InputBar({ if (el !== null) toggleCommandMenu?.(selectionOf(el)) } - const ordinary = subagent === null - const stopping = running && ordinary + // Ordinary sessions and continuable children stop through the same primary + // action; one-shot children stay send-and-stop-free (read-only takeover). + const stopping = running && (subagent === null || continuable) const primaryLabel = stopping ? t('input.stop') : t('input.send') const onPrimary = (): void => { if (stopping) { @@ -478,9 +483,11 @@ export function InputBar({ disabled={locked} readOnly={machineBusy} data-phase={input?.phase ?? 'inert'} - placeholder={placeholder ?? (disabled - ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + placeholder={placeholder ?? (parentOffline + ? t('placeholder.parentOffline') + : disabled + ? t('placeholder.unavailable') + : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 47791a6090..89f0d0a5f4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -143,7 +143,8 @@ function bench(over?: BenchOptions) { } const view = render() const textarea = view.container.querySelector('textarea')! - const stopping = over?.running === true && over.subagent === undefined + const stopping = over?.running === true + && (over.subagent === undefined || over.subagent.address.mode === 'continuable') const button = view.container.querySelector( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, )! @@ -250,8 +251,8 @@ describe('running and lock semantics (queue cut 1)', () => { expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue') }) - it('running subagent primary admits a follow-up instead of exposing Stop', () => { - const { button, sink, stop } = bench({ + it('running continuable subagent turns the same primary into Stop while typing stays free', () => { + const { button, textarea, sink, stop } = bench({ running: true, draft: '后续消息', subagent: { @@ -263,23 +264,54 @@ describe('running and lock semantics (queue cut 1)', () => { parentAvailable: true, }, }) - expect(button.getAttribute('aria-label')).toBe('发送消息') + // One primary action only: Send switched to Stop, no side-by-side interrupt. + expect(button.getAttribute('aria-label')).toBe('停止生成') + expect(textarea.disabled).toBe(false) fireEvent.click(button) + expect(stop).toHaveBeenCalledTimes(1) + expect(sink).not.toHaveBeenCalled() + // Enter still queues the follow-up while the primary offers Stop. + fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('后续消息', 'queue') - expect(stop).not.toHaveBeenCalled() + }) - const empty = bench({ + it('parent-offline running continuable locks the input but keeps the same primary Stop usable', () => { + const { button, textarea, stop, view } = bench({ running: true, + draft: '', subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable', }, + parentAvailable: false, + }, + }) + expect(textarea.disabled).toBe(true) + expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行') + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect(button.getAttribute('aria-label')).toBe('停止生成') + expect(button.disabled).toBe(false) + fireEvent.click(button) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('running one-shot subagent never exposes Stop', () => { + const { button, stop } = bench({ + running: true, + draft: '不可停止', + subagent: { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'one-shot', + }, parentAvailable: true, }, }) - expect(empty.button.disabled).toBe(true) + expect(button.getAttribute('aria-label')).toBe('发送消息') + expect(stop).not.toHaveBeenCalled() }) it('keeps both running subagent Enter gestures on Queue transport', () => { diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 8bb19b2ba2..d1b0852953 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: cb210b219a8c66985eb4e1370468372eed9614b4 -README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2 +README.md: de55963bbe356e18a4e2b653be823cc5015c1bb7 +README.zh.md: ce4244db094bb8b602b854289ec000477bf45f0b diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index cb210b219a..de55963bbe 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -6,7 +6,7 @@ Web subagent feature owner: contributes the lazily expandable catalog tree to `c The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. -A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). +A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input is disabled but whose primary Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing stays free while the child runs because every follow-up joins the child's FIFO inbox, and the single primary action follows the ordinary convention — Send while idle, Stop while running, routed through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md). Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar. @@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work -- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button. +- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 857e92d05a..ce4244db09 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -6,7 +6,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 -one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。 +one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间,selector 会让位给普通编辑器——其输入区被禁用,但 primary Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入保持自由,因为每条后续消息都会进入 child 的 FIFO inbox,而唯一的 primary 操作遵循普通约定——空闲时为 Send,运行时为 Stop,经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md) 规定。 普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。 @@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) ## 已知限制与暂缓事项 -- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开符合授权边界的取消按钮。 +- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 95626cbaf1..73c7f9a6cc 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -43,7 +43,11 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } - return subagent.parentAvailable ? null : { reason: 'parent-unavailable' } + if (subagent.parentAvailable) return null + // A RUNNING parent-offline continuable child keeps the default composer: + // its input is disabled there, but the same primary Stop stays available so + // the child can be interrupted. Once it stops, this takeover returns. + return owner.session?.running === true ? null : { reason: 'parent-unavailable' } } /** diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 09221e1e94..861546c19c 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -161,19 +161,26 @@ describe('apply', () => { const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null const owner = ( subagent: ConversationSnapshot['subagent'] | undefined, + running = false, ): ComposerChainProps => ({ interactions: [], session: subagent === undefined ? undefined - : ({ subagent } as unknown as ConversationSnapshot), + : ({ subagent, running } as unknown as ConversationSnapshot), }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }))) .toEqual({ reason: 'one-shot' }) + // One-shot stays read-only even while running: it has no stop action. + expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) + .toEqual({ reason: 'one-shot' }) expect(select(owner({ address, parentAvailable: true }))).toBeNull() expect(select(owner({ address, parentAvailable: false }))) .toEqual({ reason: 'parent-unavailable' }) + // A RUNNING parent-offline continuable yields the default composer, whose + // disabled input still carries the primary Stop; stopped, it takes back over. + expect(select(owner({ address, parentAvailable: false }, true))).toBeNull() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 1997f0ea47..1a8ef6508a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -55,6 +55,7 @@ "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/subagent-interrupt.e2e.ts", + "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 4b29f9ca7a7a72f1f6289717d5460ffff8b690cb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 7 Aug 2026 16:13:54 +0800 Subject: [PATCH 024/262] test(web): refresh interrupt UI golden after master merge --- .../snapshots/subagent-interrupt/offline-composer.expected.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index 6ea7b83bff..a0baa9a44f 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -9,9 +9,6 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img From d769d3cbb7c32cc4f1a4ff32cf5b30af96ea9f34 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 13:46:55 +0800 Subject: [PATCH 025/262] feat(subagent): let ancestors interrupt descendants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interrupt_agent(agent_id) passes the calling agent as the ancestor authority for ctx.subagents.interrupt(); the service verifies live registry identity and recorded lineage, so a direct child or deeper descendant stops with the same generic parameter while send_message keeps its exact-direct-parent authority. Discovery: list_agents gains an optional scope. descendants walks the new SubagentService.listDescendants() — one lineage trace flattened in stable pre-order across ordinary and one-shot intermediates, each entry carrying its verified parentId and depth — and every status now comes from the live Agent registry (running/idle/complete). Refs #1535 --- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 8 +- ...ble-subagent-catalog-and-list-agents.zh.md | 8 +- ...6-continuable-subagent-interrupt.i18n.yaml | 4 +- ...26-08-06-continuable-subagent-interrupt.md | 6 +- ...08-06-continuable-subagent-interrupt.zh.md | 6 +- apps/web/tests/shipped-composition.e2e.ts | 1 + docs/cordis-catalog/services.md | 19 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 23 +- docs/core-data-structures/subagent.zh.md | 23 +- .../system-prompt.expected.md | 21 +- .../tool-schemas.expected.json | 31 ++- .../both-mode-turn/tool-schemas.expected.json | 31 ++- .../code-mode-turn/system-prompt.expected.md | 21 +- .../lsp-definition/tool-schemas.expected.json | 31 ++- .../pty-tools/tool-schemas.expected.json | 31 ++- .../tool-schemas.expected.json | 31 ++- .../tool-schemas.1.expected.json | 31 ++- .../tool-schemas.1.expected.json | 31 ++- .../tool-schemas.1.expected.json | 31 ++- .../text-turn/tool-schemas.expected.json | 31 ++- .../web-fetch/tool-schemas.expected.json | 31 ++- .../cordis/tool-cordis/src/api-catalog.ts | 8 + .../core/tools/tests/gen-tool-catalog.spec.ts | 3 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 5 +- packages/subagent/subagent/README.zh.md | 7 +- packages/subagent/subagent/src/index.ts | 31 ++- .../subagent/subagent/src/list-children.ts | 159 ++++++++++++-- .../subagent/tests/list-children.spec.ts | 194 +++++++++++++++++ .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 28 ++- .../tool-subagent-control/README.zh.md | 28 ++- .../tool-subagent-control/src/index.ts | 56 ++++- .../tool-subagent-control/src/list-agents.ts | 124 ++++++++--- .../tests/list-agents.spec.ts | 165 +++++++++++++- .../tests/tool-subagent-control.spec.ts | 202 +++++++++++++++++- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 5 + 41 files changed, 1325 insertions(+), 162 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 2acc3dd5b5..c525cc8f43 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.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-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2 +2026-07-22-durable-subagent-catalog-and-list-agents.md: 8337a926238bf7fc4395896fcd4ca180c9c1ac1c +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: bcf2895a9a69c8cff949788c78158bfccd198c5c diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index b96d6e1dd3..8337a92623 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -23,7 +23,7 @@ Parent-to-child enumeration is a service capability with consumer-specific proje - report corpus activity separately as `running` or `inactive`, without implying completion or resumability; - return every resulting child in stable `createdAt` ascending, child-id ascending order. -Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and maps `inactive` to its existing `complete` presentation; a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. +Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and refines status through the live Agent registry (`running`/`idle`/`complete`); a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. ### Enumeration decision @@ -52,7 +52,7 @@ If measured scale later requires an index, that index is derived state: session A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. `mode` is durable creation policy; `activity` is a process-local corpus snapshot. Activity is neither `AgentStatus`, the manager's internal Activation state, nor a durable outcome, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this feature. -The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It keeps diagnostics, drops `one-shot` child entries, maps a continuable child's `running` activity to `running` and `inactive` activity to `complete`, then renders ` [] —