Merge origin/master into codex/sqlite-metadata-contracts
This commit is contained in:
+6
@@ -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
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.md: bf0ae468c4783b73e2dbd0e1bc50b9bd2f50cb3f
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 17913d2636e3ee5e5ae69f9c554935ba861d14d9
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Unify agent delivery and coalesce injected context into user/message
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work.
|
||||
|
||||
Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`).
|
||||
|
||||
## Decision
|
||||
|
||||
**One acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering.
|
||||
|
||||
**inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`.
|
||||
|
||||
**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`.
|
||||
|
||||
**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata.
|
||||
|
||||
**Delivery returns an id.** Each delivery method returns an opaque branded `AgentMessageId` for the accepted input. FIFO methods carry it through their inbox lifecycle events; injection bypasses those events.
|
||||
|
||||
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, steering/wakeup facts, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
|
||||
|
||||
**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead.
|
||||
- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
|
||||
- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
|
||||
|
||||
## Consequences
|
||||
|
||||
The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
|
||||
|
||||
Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple.
|
||||
|
||||
## Related
|
||||
|
||||
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on.
|
||||
- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event.
|
||||
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends.
|
||||
- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public helpers and fully resolved acceptance interface.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Agent Note: 统一 agent 投递并把注入的上下文合并进 user/message
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-unified-send-and-coalesced-user-messages.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。
|
||||
|
||||
另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。
|
||||
|
||||
## 决策
|
||||
|
||||
**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。
|
||||
|
||||
**inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。
|
||||
|
||||
**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。
|
||||
|
||||
**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。
|
||||
|
||||
**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。
|
||||
|
||||
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、steering/wakeup 事实、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。
|
||||
|
||||
**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。
|
||||
- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。
|
||||
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
|
||||
|
||||
## 后果
|
||||
|
||||
具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
|
||||
|
||||
在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。
|
||||
|
||||
## 相关
|
||||
|
||||
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。
|
||||
- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。
|
||||
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。
|
||||
- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开辅助方法以及接受完全解析输入的接口。
|
||||
+6
@@ -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
|
||||
2026-07-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb
|
||||
2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Name public agent delivery by intent
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-intent-named-agent-delivery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A configurable `send(content, { target?, wakeup?, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. Most callers have one semantic intent, while some adapters already possess exact routing facts and should not have to reverse-map them into a helper name.
|
||||
|
||||
Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter.
|
||||
|
||||
## Decision
|
||||
|
||||
`Agent` is a structural interface with four intent-named delivery helpers:
|
||||
|
||||
- `followup()` queues an ordinary turn and wakes the driver.
|
||||
- `queue()` queues an ordinary turn without waking an idle driver.
|
||||
- `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn.
|
||||
- `inject()` appends model-facing context without running the model.
|
||||
|
||||
`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts.
|
||||
|
||||
`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery.
|
||||
|
||||
The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state.
|
||||
|
||||
**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection.
|
||||
|
||||
**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded.
|
||||
|
||||
**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation.
|
||||
|
||||
**Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused agent-loop coverage exercises direct fully resolved acceptance, waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes, requires every `ResolvedAgentInput` field, requires empty contexts on its injection variant, and keeps routing fields out of `SendOptions`. The keyless Cordis inspection snapshot pins the structural interface without an abstract-class implementation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Ordinary callers choose one verb instead of encoding two routing axes; advanced callers may submit the exact discriminated route. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface preserves simple adapters and fakes. Adding a common delivery intent still requires an explicit public helper and mapping rather than another optional matrix combination.
|
||||
|
||||
The advanced method adds interface surface and requires structural fakes to implement it. In return, resolved routing has one typed representation, while helper defaults and mappings stay beside the only implementation that owns them.
|
||||
|
||||
## Related
|
||||
|
||||
- [unified delivery and coalesced user messages](2026-07-22-unified-send-and-coalesced-user-messages.md) owns the shared acceptance mechanism, inbox lifecycle, and durable event convergence this decision narrows at the public seam.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: 按意图命名公开的 agent 投递
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-intent-named-agent-delivery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
可配置的 `send(content, { target?, wakeup?, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。大多数调用方只有一种语义意图,而有些适配器已经持有确切的路由信息,不应再被迫将这些信息反向映射为某个辅助方法名称。
|
||||
|
||||
通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。
|
||||
|
||||
## 决策
|
||||
|
||||
`Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法:
|
||||
|
||||
- `followup()` 将一个普通轮次入队并唤醒驱动器。
|
||||
- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。
|
||||
- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。
|
||||
- `inject()` 追加面向模型的上下文,但不运行模型。
|
||||
|
||||
`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词。
|
||||
|
||||
`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。
|
||||
|
||||
结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。
|
||||
|
||||
**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。
|
||||
|
||||
**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃。
|
||||
|
||||
**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作。
|
||||
|
||||
**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。
|
||||
|
||||
## 验证
|
||||
|
||||
聚焦的 agent-loop 覆盖率测试通过公开方法覆盖直接接受完全解析的输入、唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,要求提供 `ResolvedAgentInput` 的每个字段,要求其注入变体的上下文为空,并确保 `SendOptions` 不包含路由字段。无密钥的 Cordis 检查快照固定了不采用抽象类实现的结构化接口。
|
||||
|
||||
## 后果
|
||||
|
||||
普通调用方选择一个动词即可,无需编码两条路由轴;高级调用方则可提交经过判别的精确路由。具体循环保留一条接受路径和一个归属边界,而结构化接口保留了对简单适配器和测试替身的支持。新增一种常见投递意图时,仍需要显式提供公开辅助方法及其映射,而不是再增加一种可选的矩阵组合。
|
||||
|
||||
这个高级方法会扩大接口范围,并要求结构化测试替身实现它。作为回报,解析后的路由只有一种类型化表示,而辅助方法的默认值和映射仍留在拥有它们的唯一实现旁边。
|
||||
|
||||
## 相关
|
||||
|
||||
- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。
|
||||
+6
@@ -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
|
||||
2026-07-24-recursive-python-sdk-session-notifications.md: c90213659391b565acd043a1be64e225f8babd31
|
||||
2026-07-24-recursive-python-sdk-session-notifications.zh.md: 214a5ef924dcc9da3a97aab6385837acd2b364d9
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Recursive Python SDK session notifications
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-recursive-python-sdk-session-notifications.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Python SDK filtered turn notifications by comparing each payload directly with the root session id. This admitted a direct child's lifecycle because its parent id named the root, but rejected a grandchild's lifecycle and every descendant `session.event`. The JSON-RPC server still emitted those notifications, so they accumulated on the low-level global queue while high-level consumers lost nested trajectory relationships and completion states.
|
||||
|
||||
## Decision
|
||||
|
||||
`HarnessClient` records every valid `subagent.started` child-to-parent edge before dispatching the notification. A later `subagent.finished` routes by its immutable parent id but never rewrites current ancestry, so an older run that settles after its child id has been reused cannot displace the replacement session. Other session notifications resolve their session id by walking that client-lifetime ancestry graph to the requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process.
|
||||
|
||||
`Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add a root session id to every JSON-RPC notification.** The server already provides exact immediate-parent edges, and duplicating transitive ancestry on the wire would make every producer responsible for client subscription state.
|
||||
|
||||
**Limit subagents to one level.** A deployment can set `maxDepth: 1`, but changing the SDK to depend on that policy would silently misreport valid recursive compositions.
|
||||
|
||||
**Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree.
|
||||
|
||||
**Expose and index every subagent run id on the JSON-RPC wire.** Exact run identity is useful when a client must correlate two concurrent outcomes for the same child, but session-tree routing already has the authoritative start edge and each terminal notification's immutable parent. Expanding the protocol is unnecessary for this ownership decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one current parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, ancestry reuse across subscriptions, and reused child ids whose older runs settle out of order.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Python SDK 递归会话通知
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-recursive-python-sdk-session-notifications.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较来过滤轮次通知。直接子 agent 的生命周期通知因 parent ID 指向根会话而能够通过,但孙级生命周期通知与所有后代 `session.event` 都会被拒绝。JSON-RPC 服务器仍会发出这些通知,因此它们会堆积在底层全局队列中,而高层消费者会丢失嵌套轨迹的关系与结束状态。
|
||||
|
||||
## 决策
|
||||
|
||||
`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。
|
||||
|
||||
`Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在每条 JSON-RPC 通知中加入根会话 ID。** 服务器已经提供精确的直接父子关系;在线路协议中重复传递祖先关系,会迫使每个生产者承担客户端订阅状态的职责。
|
||||
|
||||
**把 subagent 限制为一层。** 部署可以设置 `maxDepth: 1`,但让 SDK 依赖该策略,会对合法的递归组合产生静默误报。
|
||||
|
||||
**只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。
|
||||
|
||||
**在 JSON-RPC 线路上公开并索引每个 subagent run ID。** 当客户端必须关联同一 child 的两个并发结果时,精确 run 身份很有价值;但会话树路由已经拥有权威 start 关系和每条终止通知中不可变的 parent。没有必要为这一归属决策扩展协议。
|
||||
|
||||
## 后果
|
||||
|
||||
高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b
|
||||
2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca
|
||||
2026-07-19-model-facing-goal-tools.md: 286329390a058c0302520fd2203e5becb8c81395
|
||||
2026-07-19-model-facing-goal-tools.zh.md: b0b4fc99ada3597fbab58081f52309e21dd43bac
|
||||
@@ -16,11 +16,11 @@ The surface also needs to preserve the separation between durable state and live
|
||||
|
||||
### Tools and model contract
|
||||
|
||||
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code.
|
||||
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
|
||||
|
||||
The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
|
||||
|
||||
All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state.
|
||||
All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state.
|
||||
|
||||
An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding.
|
||||
|
||||
@@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
|
||||
Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/partial-edit/pause/resume behavior including strict-schema fillers, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -48,6 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
|
||||
- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not.
|
||||
- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective.
|
||||
- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal.
|
||||
- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -56,6 +57,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
|
||||
- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives.
|
||||
- Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate.
|
||||
- Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance.
|
||||
- Strict-schema provider fillers interoperate without allowing meaningful cross-action updates.
|
||||
|
||||
## Known limitations and deferred work
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ Status: implemented
|
||||
|
||||
### 工具与模型契约
|
||||
|
||||
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。
|
||||
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
|
||||
|
||||
提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
|
||||
|
||||
三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。
|
||||
三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。
|
||||
|
||||
自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。
|
||||
|
||||
@@ -38,7 +38,7 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。
|
||||
单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -48,6 +48,7 @@ Status: implemented
|
||||
- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。
|
||||
- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。
|
||||
- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。
|
||||
- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -56,6 +57,7 @@ Status: implemented
|
||||
- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。
|
||||
- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。
|
||||
- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。
|
||||
- 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-21-tui-resume-command.md: 2282eaa9bff83fdb75bdce315d6b17bf8f9ea303
|
||||
2026-07-21-tui-resume-command.zh.md: f9d989a5b4e7eb106ff21c5a4fcfa770a5962343
|
||||
2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b
|
||||
2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Resume command hint and `/resume`
|
||||
# Agent Note: Product-level TUI session resume
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,36 +6,34 @@ English | [中文](2026-07-21-tui-resume-command.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI can resume a session by launch (`RESUME_SESSION_ID=<id> dsh` feeding `dsh-tui-demo`'s `resumeSessionId`), but nothing told the user the command. On exit the session id survived only in the log and `./.sessions` filenames — the [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the last place it was shown — so resuming meant hunting for the id and reconstructing the invocation. There was also no in-session way to see which sessions in this workspace are resumable.
|
||||
The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement.
|
||||
|
||||
## Decision
|
||||
|
||||
A single optional `resumeCommand` config field on `dsh-tui` gates both surfaces: a shell command template whose every `{session}` is replaced with the live session id (e.g. `dsh --resume {session}`). Absent, neither surface appears.
|
||||
`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled.
|
||||
|
||||
- **Exit hint.** Process-exiting shutdown prints `To resume this session: <command>` (muted label) via `runtime.terminal.write` after `ui.stop()`, before `runtime.exit`. It prints only once the session is durably persisted: `currentResumeCommand()` scans the session list for the current id and returns `undefined` if it is absent, so a session abandoned before its first flush advertises no command that would fail to load.
|
||||
- **`/resume`.** Lists this workspace's persisted sessions newest-first, each with its resume command, marking the current one `(current)`. It warns when `resumeCommand` is unconfigured or no persistence backend is mounted, and notes when nothing is persisted yet. The listing is asynchronous, so the transcript updates a tick after submit.
|
||||
- **Listing.** `listWorkspaceSessions()` reads the optional `sessionPersistence` service's `list()`, keeps headers whose `cwd === agent.session.header.cwd`, and sorts by `createdAt` descending. A `list()` rejection is swallowed to `[]` — a persistence failure must never block terminal exit or crash `/resume`.
|
||||
`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process.
|
||||
|
||||
`sessionPersistence` is an optional injected service reached through `ctx.get('sessionPersistence')` (not `inject`), declared as an optional peer dependency. Without a backend the field still parses; the exit hint and `/resume` degrade to nothing and the unconfigured/no-backend warnings respectively. `dsh-tui-demo` forwards `resumeCommand` to `dsh-tui`, and the runnable `examples/tui-agent` leaves set `dsh --resume {session}`. The `dsh` CLI (`apps/cli`) parses that `--resume <id>` flag through `parseResumeArg` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md), setting `RESUME_SESSION_ID` before boot so the printed command runs back through the config's existing `resumeSessionId` intake; a mistyped or repeated flag fails loud rather than silently starting fresh.
|
||||
After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Hardcode or auto-detect the resume invocation.** Rejected: the launch command is deployment-specific — the env-var name, binary, and flags all vary — so a `DEFAULT_*` constant would be a fixed tunable, not configurability. A template owned by the leaf keeps the choice where the deployment lives, and `{session}` is the only substitution the TUI must know.
|
||||
**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`.
|
||||
|
||||
**Two config fields, one per surface.** Rejected: both render the identical command, so one field keeps them symmetric and unable to drift; there is no deployment that wants the hint but not the listing.
|
||||
**Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path.
|
||||
|
||||
**Print the exit hint unconditionally.** Rejected: resuming a session id that never flushed fails to load, so advertising it is a broken instruction. Gating on the id appearing in `list()` costs one scan and only ever suppresses a dead command.
|
||||
**Treat a missing adapter as a missing session.** Rejected: storage validity and current route availability are independent facts. The selector keeps the row and names the unavailable provider/model.
|
||||
|
||||
**Resume in place from `/resume` (relaunch or reattach).** Rejected: the TUI does not own agent lifecycle or process spawning ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). Printing a copyable command respects that boundary and matches the `pi --resume` affordance the request cited.
|
||||
|
||||
**Make `sessionPersistence` a required `inject`.** Rejected: the TUI must run without persistence (fixtures, ephemeral runs). An optional service that degrades preserves that, and matches the [`session-query`](../../../../packages/session-query/session-query/package.json) precedent for the same optional peer.
|
||||
**Persist goal activation across resume.** Rejected: durable intent is not authorization to continue after a human or process boundary. Goal phase survives; automatic continuation does not.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dsh-tui` gains an optional peer dependency on `@deepseek-ai/dsh-session-persistence` (`peerDependenciesMeta.optional`), matching `session-query`; the package still loads and passes its coverage gate without a backend mounted.
|
||||
- The help line and autocomplete gain `/resume`; two existing snapshots re-recorded for the wider help line, and a new `resume-sessions` checkpoint pins the rendered listing.
|
||||
- `dsh-tui-demo` and both `examples/tui-agent` leaves carry `resumeCommand`, so a real TUI run now prints its own resume command on exit, and the `dsh` CLI accepts the printed `--resume <id>` flag to run it.
|
||||
- Concurrent processes can select or resume the same persisted session because preflight does not serialize them.
|
||||
- `/resume` depends on `session-query` for discovery and complete-log reads, but persistence and host handoff remain optional; without a host, the command fallback stays usable.
|
||||
- Process replacement intentionally restarts Loader composition. Runtime-only state is rebuilt, while only logged or header-backed session state survives.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins the seven behaviors: the exit hint prints only when the current session is persisted, is omitted when it is not and when `list()` rejects; `/resume` lists workspace sessions newest-first with the `(current)` marker and cwd filter, warns when unconfigured and when no backend is mounted, and notes when nothing is persisted. The `resume-sessions` snapshot verifies the full rendered frame. The harness provides a fake `sessionPersistence` through `ctx.provide`. For the `--resume` flag, `packages/ui/app-boot/tests/app-boot.spec.ts` pins `parseResumeArg` (space and inline forms, position independence, and the fail-loud on a valueless, empty, or repeated flag), and `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots `apps/cli` with `--resume <missing-id>` and asserts the config resume fails loud — proving the flag reaches the `resumeSessionId` intake.
|
||||
TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Resume command hint and `/resume`
|
||||
# Agent Note: 产品级 TUI 会话恢复
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,36 +6,34 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 本就能通过启动参数恢复会话(`RESUME_SESSION_ID=<id> dsh` 喂给 `dsh-tui-demo` 的 `resumeSessionId`),但没有任何地方告诉用户这条命令。退出时会话 id 只残留在会话日志和 `./.sessions` 文件名里——[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md) 移除了它最后一处显示位置——因此恢复意味着先翻出 id 再拼回调用命令。也没有任何会话内的方式查看当前 workspace 里哪些会话可恢复。
|
||||
原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` 上一个可选的 `resumeCommand` 配置字段同时管辖两处出口:一个 shell 命令模板,其中每一处 `{session}` 都会被替换为当前会话 id(例如 `dsh --resume {session}`)。未设置时两处都不出现。
|
||||
`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。
|
||||
|
||||
- **退出提示。** 以退出进程方式关闭时,在 `ui.stop()` 之后、`runtime.exit` 之前,经由 `runtime.terminal.write` 打印 `To resume this session: <command>`(弱化的标签)。仅当会话已持久化时才打印:`currentResumeCommand()` 在会话列表中查找当前 id,若不存在则返回 `undefined`,因此在首次刷盘前就被放弃的会话不会宣传一条注定加载失败的命令。
|
||||
- **`/resume`。** 按最新在前列出当前 workspace 里已持久化的会话,每条附带其恢复命令,并给当前会话标注 `(current)`。当 `resumeCommand` 未配置或未挂载持久化后端时给出告警,尚无任何会话被持久化时给出提示。列出是异步的,因此提交后文本记录会在下一个 tick 更新。
|
||||
- **列出逻辑。** `listWorkspaceSessions()` 读取可选的 `sessionPersistence` 服务的 `list()`,保留 `cwd === agent.session.header.cwd` 的头部,并按 `createdAt` 降序排序。`list()` 拒绝时吞掉为 `[]`——持久化失败绝不能阻塞终端退出或让 `/resume` 崩溃。
|
||||
`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。
|
||||
|
||||
`sessionPersistence` 是一个通过 `ctx.get('sessionPersistence')`(而非 `inject`)获取的可选注入服务,声明为可选的对等依赖(peer dependency)。没有后端时该字段仍能解析;退出提示与 `/resume` 分别退化为不做任何事、以及给出未配置/无后端告警。`dsh-tui-demo` 将 `resumeCommand` 转发给 `dsh-tui`,可运行的 `examples/tui-agent` 叶子配置设为 `dsh --resume {session}`。`dsh` CLI(`apps/cli`)通过 [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `parseResumeArg` 解析该 `--resume <id>` 标志,在启动前设置 `RESUME_SESSION_ID`,因此打印出的命令会重新走回配置中既有的 `resumeSessionId` 入口;拼写错误或重复的标志会直接报错退出,而非悄悄开启一个新会话。
|
||||
预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
|
||||
|
||||
`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**硬编码或自动探测恢复调用命令。** 否决:启动命令与部署强相关——环境变量名、可执行文件、参数都各不相同——因此一个 `DEFAULT_*` 常量只会是固定的可调项,而非可配置项。由叶子拥有的模板把这个选择留在部署所在之处,而 `{session}` 是 TUI 唯一需要知道的替换。
|
||||
**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。
|
||||
|
||||
**两个配置字段,每处出口一个。** 否决:两处渲染的是完全相同的命令,因此单个字段让它们保持对称、不会漂移;不存在只想要提示而不想要列表的部署。
|
||||
**在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。
|
||||
|
||||
**无条件打印退出提示。** 否决:恢复一个从未刷盘的会话 id 会加载失败,宣传它就是一条错误指令。以 id 是否出现在 `list()` 中为条件仅需一次扫描,且只会抑制一条注定失败的命令。
|
||||
**把适配器缺失视为会话缺失。** 否决:存储有效性和当前路由可用性是相互独立的事实。选择器会保留该行,并指出不可用的提供方/模型。
|
||||
|
||||
**从 `/resume` 就地恢复(重启或重连)。** 否决:TUI 不拥有 agent 生命周期或进程创建([全屏 TUI 门面 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。打印一条可复制的命令尊重这条边界,也契合需求所引用的 `pi --resume` 用法。
|
||||
|
||||
**把 `sessionPersistence` 设为必需的 `inject`。** 否决:TUI 必须能在无持久化时运行(fixture(测试前置数据)、临时运行)。一个会优雅退化的可选服务保住了这一点,也与 [`session-query`](../../../../packages/session-query/session-query/package.json) 对同一可选对等依赖的先例一致。
|
||||
**恢复会话时延续目标激活状态。** 否决:持久意图并不代表跨越用户或进程边界后仍获授权继续执行。目标阶段会保留,但不会自动续跑。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dsh-tui` 新增对 `@deepseek-ai/dsh-session-persistence` 的可选对等依赖(`peerDependenciesMeta.optional`),与 `session-query` 一致;未挂载后端时该包仍能加载并通过其覆盖率门禁。
|
||||
- 帮助行和自动补全新增 `/resume`;两个既有快照因帮助行变宽而重新录制,新增的 `resume-sessions` 检查点固定渲染出的列表。
|
||||
- `dsh-tui-demo` 及两个 `examples/tui-agent` 叶子配置都带上 `resumeCommand`,因此真实的 TUI 运行现在退出时会打印自己的恢复命令,且 `dsh` CLI 接受打印出的 `--resume <id>` 标志来运行它。
|
||||
- 预检不会串行化不同进程;多个进程可以并发选择或恢复同一个持久化会话。
|
||||
- `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。
|
||||
- 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定这七种行为:退出提示仅在当前会话已持久化时打印,未持久化时以及 `list()` 拒绝时都不打印;`/resume` 按最新在前列出 workspace 会话并带 `(current)` 标注与 cwd 过滤、未配置时告警、无后端时告警、尚无持久化时给出提示。`resume-sessions` 快照验证完整渲染帧。测试脚手架通过 `ctx.provide` 提供一个假的 `sessionPersistence`。对于 `--resume` 标志,`packages/ui/app-boot/tests/app-boot.spec.ts` 固定 `parseResumeArg`(空格形式与内联形式、位置无关性,以及在标志缺值、为空或重复时直接报错退出),`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 用 `--resume <missing-id>` 启动 `apps/cli` 并断言配置恢复直接报错退出——证明该标志抵达了 `resumeSessionId` 入口。
|
||||
TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。
|
||||
@@ -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
|
||||
2026-07-23-trajectory-step-cell.md: 414c3aac856fb5e60f0e4cf42f8e7b410cdf3413
|
||||
2026-07-23-trajectory-step-cell.zh.md: aa76b422f165ebf6918b3781fdfe38797a34ba51
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Trajectory step cell and turn list chrome
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-trajectory-step-cell.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome.
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome:
|
||||
|
||||
- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection.
|
||||
- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8.
|
||||
- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only (including the empty fallback when there is no text block), and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). `user/message` has no wire turn, so each User row is enclosed in the next assistant/steering turn, else the in-flight `partial` turn, else `lastAssistantTurn + 1` (or `1`). Context nodes emit no cell but still advance the Message duration cursor.
|
||||
|
||||
[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time (including skipped context); Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`.
|
||||
|
||||
**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval.
|
||||
|
||||
**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration.
|
||||
|
||||
**Hang usage on the first expanded row.** Rejected; usage attaches to Message only.
|
||||
|
||||
**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Trajectory 步骤单元格与轮次列表 chrome
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-trajectory-step-cell.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome:
|
||||
|
||||
- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。
|
||||
- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。
|
||||
- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上(含无 text 块时的空回退行),并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。`user/message` 无线上 turn,故每条 User 行归入下一 assistant/steering 的 turn,否则归入进行中的 `partial` turn,否则为 `lastAssistantTurn + 1`(或 `1`)。context 节点不产出单元格,但仍推进 Message 耗时游标。
|
||||
|
||||
[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间(含跳过的 context);Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。
|
||||
|
||||
**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。
|
||||
|
||||
**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。
|
||||
|
||||
**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。
|
||||
|
||||
**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。
|
||||
|
||||
## Consequences
|
||||
|
||||
一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。
|
||||
+6
@@ -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
|
||||
2026-07-24-new-session-clears-to-empty-state.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab
|
||||
2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: New Session clears onto the empty-state launch
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-new-session-clears-to-empty-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Sidebar "New Session" created and opened a blank session immediately, so the center column showed `ConversationRoot` with an empty transcript and the resident composer. The Figma NEW SESSION screen (`EmptyState` + shared `InputBar` hero) only rendered when `sessions.current` was already undefined, so the launch page was unreachable from the primary creation control.
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionsService.clear()` wipes the persisted selection and `list.current`. Top-level sidebar creation entries (`onCreate()` with no cwd — New Session and New Workspace) call `clear()` so `AppFrame` renders `conversation.empty`. The empty state's first send still runs `conversation.startSession` (create → open → send) and reuses the same `InputBar` component as the resident composer (`variant="hero"`). Per-project "+" (`onCreate(cwd)`) keeps create-then-open until the empty-state picker can accept a seeded cwd.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep create-then-open for New Session and add a second empty chrome inside ConversationRoot when the transcript is empty.** Rejected: that duplicates the launch InputBar and breaks the empty→content ruling that one InputBar moves position rather than swapping components.
|
||||
|
||||
**Route New Session through a dedicated route or slot outside selection.** Rejected for this pass: `conversation.empty` already owns the launch UI; clearing `current` is the existing empty branch.
|
||||
|
||||
## Consequences
|
||||
|
||||
New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero (Input_Bottom 75:8208) as fish + title, a Menu-backed workspace chip above the card, then shared `InputBar` (`variant="hero"`, max-width 800, r20 card matching the composer — not a taller r24 hero), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776` asset ratio) so it scales with it. The chip uses the soft interactive hover fill + 12px radius from 75:8208 and opens MenuDropdown (figma 122:9481; `--dsw-specific-menu` + `--dsw-shadow-lv3`): basename rows with folder icons and a trailing check, then a separator and "New Workspace" whose submenu (figma 419:16920) offers "Use a existing folder" and "Create new". Use a existing folder opens the path Dialog (figma 451:18655 copy — "Enter an existing folder path" / Open Folder) over a full-viewport mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`) and sets the chip cwd. Create new opens the same Dialog chrome to name a folder under `host.describe().cwd`; success runs `sessions.createWorkspace` → host `session.create` (mkdir recursive) → `sessions.open`, so a default session lands in the new workspace. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: New Session clears onto the empty-state launch
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-new-session-clears-to-empty-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
侧栏「New Session」会立即创建并打开空白会话,因此中间栏显示带空 transcript(文本记录)与常驻 composer 的 `ConversationRoot`。Figma 的 NEW SESSION 屏(`EmptyState` + 共用的 `InputBar` hero)仅在 `sessions.current` 已为 undefined 时渲染,因而主创建控件无法到达启动页。
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionsService.clear()` 清除持久化选中项与 `list.current`。顶层侧栏创建入口(无 cwd 的 `onCreate()`——New Session 与 New Workspace)调用 `clear()`,使 `AppFrame` 渲染 `conversation.empty`。空态的首次发送仍走 `conversation.startSession`(create → open → send),并复用与常驻 composer 相同的 `InputBar` 组件(`variant="hero"`)。按项目的「+」(`onCreate(cwd)`)继续 create-then-open,直到空态选择器能接受预填的 cwd。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**为 New Session 保留 create-then-open,并在 transcript 为空时于 ConversationRoot 内再加一套空态 chrome。** 否决:这会重复启动页的 InputBar,并破坏 empty→content 的约定——同一 InputBar 应移动位置,而非互换组件。
|
||||
|
||||
**将 New Session 路由到选中状态之外的专用 route 或 slot。** 本轮否决:`conversation.empty` 已拥有启动 UI;清除 `current` 即是既有的空态分支。
|
||||
|
||||
## Consequences
|
||||
|
||||
New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区(Input_Bottom 75:8208):鱼标 + 标题、卡片上方由 Menu 驱动的工作区 chip,再接共用的 `InputBar`(`variant="hero"`,max-width 800,与 composer 一致的 r20 卡片——而非更高的 r24 英雄区),选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776` asset 比例,随卡片缩放。Chip 采用 75:8208 的柔和交互 hover 填充与 12px 圆角,并打开 MenuDropdown(figma 122:9481;`--dsw-specific-menu` + `--dsw-shadow-lv3`):带文件夹图标与尾随勾选的 basename 行,分隔线后是 "New Workspace",其子菜单(figma 419:16920)提供 "Use a existing folder" 与 "Create new"。Use a existing folder 打开路径 Dialog(figma 451:18655 copy — "Enter an existing folder path" / Open Folder),置于全视口遮罩(`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`)之上,并设置 chip 的 cwd。Create new 打开同一套 Dialog chrome,在 `host.describe().cwd` 下命名文件夹;成功则走 `sessions.createWorkspace` → host `session.create`(mkdir recursive)→ `sessions.open`,在新 workspace 中默认落下一会话。`InputBar` 绘制底栏 chrome(attach / Plan / Read-only / model),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。
|
||||
+6
@@ -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
|
||||
2026-07-24-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0
|
||||
2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Agent Note: Separate context injection from turn execution
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules.
|
||||
|
||||
Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer.
|
||||
|
||||
Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.”
|
||||
|
||||
`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance.
|
||||
|
||||
## Proposal
|
||||
|
||||
Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop.
|
||||
|
||||
Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape.
|
||||
|
||||
Prompt and tool extension points may still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results.
|
||||
|
||||
Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content.
|
||||
|
||||
## Injection lifecycle
|
||||
|
||||
When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request.
|
||||
|
||||
When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary.
|
||||
|
||||
A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence.
|
||||
|
||||
The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail.
|
||||
|
||||
## Extension and caller semantics
|
||||
|
||||
`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements.
|
||||
|
||||
Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam.
|
||||
|
||||
Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules.
|
||||
|
||||
This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery.
|
||||
|
||||
**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers.
|
||||
|
||||
**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution.
|
||||
|
||||
**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content.
|
||||
|
||||
**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts.
|
||||
- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`.
|
||||
- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay.
|
||||
- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush.
|
||||
- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them.
|
||||
- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains.
|
||||
- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics.
|
||||
|
||||
## Risks
|
||||
|
||||
- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries.
|
||||
- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering.
|
||||
- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract.
|
||||
- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam.
|
||||
- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically.
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Agent Note: 将上下文注入与轮次执行分离
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。
|
||||
|
||||
将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。
|
||||
|
||||
空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。
|
||||
|
||||
`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。
|
||||
|
||||
## 提案
|
||||
|
||||
将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。
|
||||
|
||||
移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。
|
||||
|
||||
提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。
|
||||
|
||||
每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。
|
||||
|
||||
## 注入生命周期
|
||||
|
||||
轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。
|
||||
|
||||
没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。
|
||||
|
||||
空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。
|
||||
|
||||
因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。
|
||||
|
||||
## 扩展点与调用方语义
|
||||
|
||||
`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。
|
||||
|
||||
调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。
|
||||
|
||||
跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。
|
||||
|
||||
本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。
|
||||
|
||||
**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。
|
||||
|
||||
**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。
|
||||
|
||||
**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。
|
||||
|
||||
**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。
|
||||
- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。
|
||||
- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。
|
||||
- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。
|
||||
- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。
|
||||
- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。
|
||||
- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。
|
||||
|
||||
## 风险
|
||||
|
||||
- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。
|
||||
- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。
|
||||
- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。
|
||||
- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。
|
||||
- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。
|
||||
+1
-1
@@ -5,7 +5,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are pro
|
||||
The TUI surface:
|
||||
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
- resumes a persisted session with `dsh --resume <session-id>` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero;
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"@deepseek-ai/dsh-host-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -19,9 +19,12 @@ import {
|
||||
loadEnv,
|
||||
loadPersonalPatches,
|
||||
parseResumeArg,
|
||||
replaceResumeArg,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { Context } from 'cordis'
|
||||
import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -65,7 +68,39 @@ export async function runTui(argv: string[]): Promise<void> {
|
||||
// after loadEnv and before boot reads it through the config's `!!js`.
|
||||
const { resumeSessionId, rest } = parseResumeArg(argv)
|
||||
if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId
|
||||
const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
|
||||
const entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
|
||||
async handoff(sessionId): Promise<never> {
|
||||
const current = app.current
|
||||
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
|
||||
const nextArgv = [
|
||||
process.execPath,
|
||||
...process.execArgv,
|
||||
entry,
|
||||
...replaceResumeArg(process.argv.slice(2), sessionId),
|
||||
]
|
||||
process.env[RESUME_SESSION_ID_ENV] = sessionId
|
||||
try {
|
||||
await current.fiber.dispose()
|
||||
execve(process.execPath, nextArgv, process.env)
|
||||
throw new Error('process replacement returned unexpectedly')
|
||||
} catch (error) {
|
||||
process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
const ctx = await boot(
|
||||
NAME,
|
||||
resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined),
|
||||
loadPersonalPatches(NAME),
|
||||
(hostCtx) => {
|
||||
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
|
||||
},
|
||||
)
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/tui"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
|
||||
@@ -17,8 +17,8 @@ sequenceDiagram
|
||||
participant Session
|
||||
participant Persistence
|
||||
participant SDK as UI or SDK listener
|
||||
User->>Agent: send(content)
|
||||
Agent-->>SDK: <code>agent/queued</code>
|
||||
User->>Agent: followup(content)
|
||||
Agent-->>SDK: <code>agent/inbox/enqueue</code>
|
||||
Agent->>Driver: queued work wakes driver
|
||||
Driver-->>SDK: <code>agent/status</code> running
|
||||
Driver->>Session: <code>turn/start</code>
|
||||
|
||||
@@ -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
|
||||
architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316
|
||||
architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee
|
||||
architecture.md: 76c58e03282ef6d736da7d65b05c534c05c4c318
|
||||
architecture.zh.md: dddccf1e9238e617a453395731ee3f620ba5749d
|
||||
+16
-16
@@ -6,7 +6,7 @@ English | [中文](architecture.zh.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute services, typed events, and disposable registrations.
|
||||
Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed services, typed events, and disposable registrations.
|
||||
|
||||
`packages/core/` groups the default agent flow; capabilities remain plugins.
|
||||
|
||||
@@ -49,7 +49,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv
|
||||
|
||||
## Event
|
||||
|
||||
Events form the service extension API; see the exhaustive [events catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md).
|
||||
Events form the service extension API; see the [catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md).
|
||||
|
||||
### Event Domains
|
||||
|
||||
@@ -63,11 +63,11 @@ Waterfall events behave like around-middleware: a listener delegates by calling
|
||||
|
||||
## Default Loop Lifecycle
|
||||
|
||||
The shipped loop runs prompt-to-checkpoint work through plugin services and events.
|
||||
The loop runs through plugin services and events.
|
||||
|
||||
A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events.
|
||||
A **session** is append-only. Each ordinary **turn** claims one queued message; injection claims none. Successors await the preceding checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A **step** is one model request plus tools; quotes in the [sequence below](agent-lifecycle.md) mark durable events.
|
||||
|
||||
Without an id, creation mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent.
|
||||
Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent.
|
||||
|
||||
### Turn Flow
|
||||
|
||||
@@ -115,37 +115,37 @@ forever:
|
||||
checkpoint persistence and notify idle/running status
|
||||
```
|
||||
|
||||
Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
|
||||
Steps assemble ordered prompt sections, tool schemas, and variables; unknown references fail turns. `dsh-system-prompt` owns identity and persona; the loop supplies `model` and `cwd` ([ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
|
||||
|
||||
Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain.
|
||||
Async `inject()` and post-tool `additionalContexts` settle after results; steering drains before `agent/post-step`. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush and discards later steering, not queued prompts.
|
||||
|
||||
Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
|
||||
Pruning precedes summaries; overflow retries require durable progress. Bounded retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
|
||||
|
||||
### Failure Boundaries
|
||||
|
||||
Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool.
|
||||
Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing.
|
||||
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
|
||||
|
||||
Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants.
|
||||
Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap).
|
||||
|
||||
### Agent Handles
|
||||
|
||||
`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber, factory provider, and consumer handle co-own teardown through one awaited disposer.
|
||||
`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown.
|
||||
|
||||
### Agent Scope
|
||||
|
||||
Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch, and every scoped contribution unwinds with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
Each agent owns a scoped `agent.ctx` over global tool, prompt, and command storage ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)); scoped listeners filter and contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication; typed resolvers derive carrier checks from `Events` and `scopeTarget` ([gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md).
|
||||
|
||||
## State
|
||||
|
||||
### Session Log
|
||||
|
||||
The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream.
|
||||
The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence share that stream.
|
||||
|
||||
**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
|
||||
**Model-visible ⟺ logged**: `step/start` messages plus the header's session prefix and folded `request/header` reconstruct every request; `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([decision](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
|
||||
|
||||
Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
|
||||
Durability is a plugin concern; backends buffer synchronous `session/event` notifications. Checkpoints drain before adapter dispatch, recorded top-level tool calls before tool dispatch, complete response/result batches at `agent/post-step`, and final turn ends. `SessionPersistence` stores `SessionEvent` plus `SessionHeader` metadata; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
|
||||
|
||||
`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)).
|
||||
|
||||
|
||||
+14
-14
@@ -49,7 +49,7 @@
|
||||
|
||||
## 事件
|
||||
|
||||
事件构成服务的扩展 API;完整清单见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。
|
||||
事件构成服务的扩展 API;参见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。
|
||||
|
||||
### 事件域
|
||||
|
||||
@@ -63,9 +63,9 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 `
|
||||
|
||||
## 默认循环生命周期
|
||||
|
||||
已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。
|
||||
循环通过插件服务和事件运行。
|
||||
|
||||
**会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。
|
||||
**会话**采用仅追加方式。每个普通**轮次**领取一条已排队的消息;注入不领取消息。后续轮次会等待前一个检查点,但可以与前一轮次共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。一个**步骤**包含一次模型请求及其工具;在[下文时序](agent-lifecycle.md)中,引号标记持久事件。
|
||||
|
||||
未提供 id 时,创建流程会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。
|
||||
|
||||
@@ -115,37 +115,37 @@ forever:
|
||||
checkpoint persistence and notify idle/running status
|
||||
```
|
||||
|
||||
每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
|
||||
各步骤会组装有序提示词片段、工具 schema 和变量;未知引用会使轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `model` 和 `cwd`([归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
|
||||
|
||||
工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering(中途引导)会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留。
|
||||
异步 `inject()` 和工具执行后的 `additionalContexts` 会在结果产生后稳定;steering(中途引导)会在 `agent/post-step` 前排空。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权,会丢弃后续 steering,而不丢弃排队提示词。
|
||||
|
||||
裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。
|
||||
裁剪先于摘要;溢出重试必须取得持久进展。有界重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。
|
||||
|
||||
### 失败边界
|
||||
|
||||
适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。
|
||||
适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。
|
||||
|
||||
其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
|
||||
其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
|
||||
|
||||
会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。
|
||||
会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。
|
||||
|
||||
### Agent 句柄
|
||||
|
||||
`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`steer()`、`inject()`、`cancel()` 和 `whenIdle()`。调用方 fiber、工厂提供方和消费方句柄通过同一个需等待完成的 disposer 共同拥有拆卸过程。
|
||||
`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。
|
||||
|
||||
### Agent 作用域
|
||||
|
||||
每个 agent 都拥有一个作用域化的 `agent.ctx`;共享存储会在全局工具、提示词和命令条目之上叠加作用域条目,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派,每项作用域贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
|
||||
每个 agent 都拥有一个作用于全局工具、提示词和命令存储的作用域化 `agent.ctx`([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md));作用域监听器会过滤分派,各项贡献会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合;类型化解析器从 `Events` 和 `scopeTarget` 推导载体检查([门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。
|
||||
|
||||
## 状态
|
||||
|
||||
### 会话日志
|
||||
|
||||
会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。
|
||||
会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保留回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化共用该事件流。
|
||||
|
||||
**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
|
||||
**模型可见 ⟺ 已记录**:`step/start` 消息、请求头中的会话前缀和折叠后的 `request/header` 共同重建每个请求;`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([决策](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
|
||||
|
||||
持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。
|
||||
持久性由插件负责;后端会缓冲同步的 `session/event` 通知。检查点会在适配器分发前排空,在工具分发前刷写已记录的顶层工具调用,在 `agent/post-step` 刷写完整的响应与结果批次,并刷写最终的轮次结束。`SessionPersistence` 存储 `SessionEvent` 和 `SessionHeader` 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。
|
||||
|
||||
`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。
|
||||
|
||||
|
||||
@@ -1571,7 +1571,7 @@ Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/inde
|
||||
|
||||
## `@deepseek-ai/dsh-tui`
|
||||
|
||||
Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter`
|
||||
Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter`
|
||||
|
||||
```ts config-catalog
|
||||
/** Serializable plugin configuration. */
|
||||
@@ -1581,11 +1581,10 @@ export interface Config extends TuiConfig {
|
||||
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
|
||||
sessionId?: string
|
||||
/**
|
||||
* Shell command template shown for resuming this session: printed on exit and
|
||||
* listed by `/resume`, with every `{session}` occurrence replaced by the live
|
||||
* session id. Absent disables both surfaces. Deployments set it only when a
|
||||
* persistence backend makes the session resumable (e.g.
|
||||
* `RESUME_SESSION_ID={session} dsh`).
|
||||
* Shell command fallback printed on exit or after selecting a session when
|
||||
* the host cannot hand off in place. Every `{session}` becomes the selected
|
||||
* id; the TUI never executes this text. Absent disables only the fallback,
|
||||
* not the interactive selector.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
}
|
||||
@@ -1600,6 +1599,8 @@ export interface TuiConfig {
|
||||
maxQuestionOptions?: number
|
||||
/** Maximum models visible at once in the model selector. */
|
||||
maxModelOptions?: number
|
||||
/** Maximum sessions visible at once in the resume selector. */
|
||||
maxResumeOptions?: number
|
||||
/** User-question panel width in terminal columns, clamped to the terminal. */
|
||||
questionDialogWidth?: number
|
||||
/** User-question panel maximum height in terminal rows. */
|
||||
@@ -1630,7 +1631,7 @@ export interface TuiConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:248`](../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui-demo`
|
||||
|
||||
|
||||
@@ -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
|
||||
extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78
|
||||
extension-cookbook.zh.md: 41cdd4a7d14f32494d1dd5ae4a63c098d5640bdc
|
||||
extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f
|
||||
extension-cookbook.zh.md: aeb5f905278c07344c68d80da05dc5daf299b4f6
|
||||
@@ -36,7 +36,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an
|
||||
|
||||
## A UI plugin
|
||||
|
||||
A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`.
|
||||
A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
@@ -54,13 +54,13 @@ export function apply(ctx: Context) {
|
||||
render(event.data.chunk.text)
|
||||
}
|
||||
})
|
||||
onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }]))
|
||||
onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }]))
|
||||
}
|
||||
```
|
||||
|
||||
## A client-driver plugin (external protocol bridge)
|
||||
|
||||
A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence.
|
||||
A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `followup()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence.
|
||||
|
||||
`packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam.
|
||||
|
||||
@@ -99,9 +99,9 @@ Every product feature maps to a listener on a documented extension seam — the
|
||||
|---|---|
|
||||
| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams |
|
||||
| `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control |
|
||||
| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue |
|
||||
| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
|
||||
| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` |
|
||||
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
|
||||
| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` |
|
||||
| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
|
||||
| System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing |
|
||||
| AGENTS.md (root) | a section provider reading the file |
|
||||
@@ -118,8 +118,8 @@ Every product feature maps to a listener on a documented extension seam — the
|
||||
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
|
||||
| Skills | section + tool registration; `inject()` skill content on invocation |
|
||||
| Memory | section provider + tool |
|
||||
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
|
||||
| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` |
|
||||
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
|
||||
| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` |
|
||||
| Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
|
||||
| Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) |
|
||||
| Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works |
|
||||
@@ -36,7 +36,7 @@ export function apply(ctx: Context) {
|
||||
|
||||
## UI 插件
|
||||
|
||||
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。
|
||||
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
@@ -54,13 +54,13 @@ export function apply(ctx: Context) {
|
||||
render(event.data.chunk.text)
|
||||
}
|
||||
})
|
||||
onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }]))
|
||||
onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }]))
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端驱动插件(外部协议桥接)
|
||||
|
||||
*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
|
||||
*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `followup()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
|
||||
|
||||
`packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。
|
||||
|
||||
@@ -99,9 +99,9 @@ export function apply(ctx: Context) {
|
||||
|---|---|
|
||||
| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 |
|
||||
| `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 |
|
||||
| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 |
|
||||
| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
|
||||
| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 |
|
||||
| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` |
|
||||
| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` |
|
||||
| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
|
||||
| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
|
||||
| AGENTS.md(根目录) | 一个读取该文件的 section provider |
|
||||
@@ -118,8 +118,8 @@ export function apply(ctx: Context) {
|
||||
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
|
||||
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
|
||||
| 记忆 | section provider + 工具 |
|
||||
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
|
||||
| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` |
|
||||
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
|
||||
| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` |
|
||||
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
|
||||
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) |
|
||||
| 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 |
|
||||
@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/created` — emit
|
||||
|
||||
@@ -54,7 +54,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:179`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
@@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -96,7 +96,77 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:498`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/dequeue` — emit
|
||||
|
||||
The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* The driver claimed one item out of the inbox: a queued item at a turn
|
||||
* boundary, or steering drained between steps. Fires after the item leaves
|
||||
* its FIFO and before it becomes a durable message.
|
||||
* @param agent - the agent whose inbox item was claimed.
|
||||
* @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/discard` — emit
|
||||
|
||||
Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Pending inbox items were dropped without delivering them, so every
|
||||
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
|
||||
* `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after
|
||||
* `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`
|
||||
* dropping pending steering (in-turn and on the post-turn late-steering
|
||||
* drain); and disposal of any still-pending items (before
|
||||
* `agent/status('disposed')`). Fires once per drop with every dropped item.
|
||||
* @param agent - the agent whose inbox items were dropped.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/enqueue` — emit
|
||||
|
||||
A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* A detached, frozen item entered the agent's inbox (queued or steering
|
||||
* FIFO). Source defaults are already applied, so `message` holds the exact
|
||||
* accepted values. This is the enqueue-time live signal; the durable record
|
||||
* is the eventual `user/message`/`steering/message`. Injection through
|
||||
* `agent.inject()` or equivalent `send()` routing bypasses the FIFOs
|
||||
* and does not emit this.
|
||||
* @param agent - the agent whose inbox received the item.
|
||||
* @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/post-step` — serial
|
||||
|
||||
@@ -119,7 +189,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:448`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
@@ -142,7 +212,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
@@ -169,28 +239,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/queued` — emit
|
||||
|
||||
Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Detached, frozen content entered the agent's inbox. Source defaults have
|
||||
* already been applied, so these are the exact values retained for the log.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the accepted content blocks retained by the inbox.
|
||||
* @param info - the accepted source, contexts, and whether it entered as steering.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [HookContext](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
@@ -215,7 +264,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha
|
||||
|
||||
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:276`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:409`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request-error` — waterfall
|
||||
|
||||
@@ -241,7 +290,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
@@ -267,7 +316,7 @@ Compose request-only messages placed before derived history. The frozen result i
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:424`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -289,16 +338,16 @@ 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:230`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:363`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event.
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking delivery does not enter `running` synchronously; drive lifecycle from this event.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
|
||||
* not enter `running` synchronously; drive lifecycle from this event.
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking
|
||||
* delivery does not enter `running` synchronously; drive lifecycle from this event.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -309,7 +358,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
|
||||
|
||||
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:197`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
@@ -332,7 +381,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
@@ -354,7 +403,7 @@ Override whether the turn continues. The default continues after tool calls or s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:474`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
@@ -376,7 +425,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `agent-loop/*`
|
||||
|
||||
|
||||
@@ -996,6 +996,14 @@ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchE
|
||||
*/
|
||||
listSessions(): Promise<SessionRecord[]>
|
||||
|
||||
/**
|
||||
* Read and replay-validate one complete logical session log without making it live.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
* @returns cloned header and complete raw event log from one observation.
|
||||
* @throws when persistence, header compatibility, or replay validation fails.
|
||||
*/
|
||||
async readSession(sessionId: SessionId): Promise<SessionLogSnapshot>
|
||||
|
||||
/**
|
||||
* Filter the complete logical corpus with provider-independent predicates.
|
||||
* @param filters - ANDed session metadata and availability clauses.
|
||||
@@ -1057,9 +1065,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace>
|
||||
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>
|
||||
```
|
||||
|
||||
Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
|
||||
Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
|
||||
|
||||
Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts)
|
||||
Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts)
|
||||
|
||||
## `ctx.sessionReferences` — `SessionReferenceService`
|
||||
|
||||
@@ -1238,7 +1246,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:607`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.sessionTitle` — `SessionTitleService`
|
||||
|
||||
@@ -1701,7 +1709,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
|
||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:132`](../../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
|
||||
*
|
||||
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
|
||||
* they only exist on {@link SurfaceEventType} variants (`user/message`,
|
||||
* `assistant/message`, `tool/result`, `context/message`, `steering/message`).
|
||||
* `assistant/message`, `tool/result`, `steering/message`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
@@ -351,7 +351,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}[T]
|
||||
```
|
||||
|
||||
The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
|
||||
The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
|
||||
|
||||
## The agent handle
|
||||
|
||||
@@ -361,8 +361,9 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Message options. An omitted source attests direct human input as `{ kind: 'user' }`
|
||||
* and may authorize policy consumers, so non-human producers must label their content.
|
||||
* Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}.
|
||||
* An omitted source attests direct human input as `{ kind: 'user' }` and may
|
||||
* authorize policy consumers, so non-human producers must label their content.
|
||||
*/
|
||||
interface SendOptions {
|
||||
source?: MessageSource
|
||||
@@ -372,19 +373,90 @@ interface SendOptions {
|
||||
* records them directly at its next checkpoint.
|
||||
*/
|
||||
contexts?: HookContext[]
|
||||
/** Opaque JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
```
|
||||
|
||||
`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them:
|
||||
|
||||
```ts type-equiv
|
||||
/** Options specific to durable synthetic context injection. */
|
||||
interface InjectOptions extends Omit<SendOptions, 'contexts'> {
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
interface InjectOptions {
|
||||
/** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */
|
||||
source?: MessageSource
|
||||
/** Opaque JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
```
|
||||
|
||||
The advanced acceptance form makes every default explicit and rules out attached contexts on injection:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Fully specified input for {@link Agent.send}. Unlike the intent-named
|
||||
* helpers, this form applies no defaults: callers provide content, source,
|
||||
* contexts, metadata (including explicit `undefined`), target, and wakeup.
|
||||
* The union excludes attached contexts from non-waking next-step injection.
|
||||
*/
|
||||
type ResolvedAgentInput = {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
meta: JsonValue | undefined
|
||||
} & (
|
||||
| { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] }
|
||||
| { target: 'next-step'; wakeup: true; contexts: HookContext[] }
|
||||
| { target: 'next-step'; wakeup: false; contexts: [] }
|
||||
)
|
||||
```
|
||||
|
||||
FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Opaque id assigned to one accepted agent input. FIFO inputs carry the same id
|
||||
* on their `agent/inbox/*` events; injection bypasses those events.
|
||||
*/
|
||||
type AgentMessageId = Branded<'AgentMessageId'>
|
||||
```
|
||||
|
||||
The `agent/inbox/*` live events carry one accepted message; injection bypasses the FIFOs and never appears on them:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One accepted FIFO message, carried by the `agent/inbox/*` live events. `id`
|
||||
* is the value returned by the accepting helper or {@link Agent.send},
|
||||
* stable across this message's enqueue, dequeue, and discard events. Source
|
||||
* defaults, when applicable, are already applied, so these are the exact values
|
||||
* the item was accepted with.
|
||||
* `steering` is true for an item drained between steps; otherwise it is claimed
|
||||
* at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable
|
||||
* model-hidden state that lands on the eventual `user/message`/
|
||||
* `steering/message`, not live-event routing data.
|
||||
*/
|
||||
interface AgentMessage {
|
||||
/** The id returned by the accepting helper or {@link Agent.send}. */
|
||||
id: AgentMessageId
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item joined the steering FIFO rather than the queued FIFO. */
|
||||
steering: boolean
|
||||
/** Whether the item wakes the driver or requests another step. */
|
||||
wakeup: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Options for {@link Agent.cancel}. */
|
||||
interface CancelOptions {
|
||||
/**
|
||||
* Preserve queued and steering inbox items instead of discarding them. The
|
||||
* active turn is still aborted, but un-started and pending work survives for a
|
||||
* later turn and no `agent/inbox/discard` fires.
|
||||
*/
|
||||
keepInbox?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Stable runtime cause accepted by {@link Agent.cancel}. */
|
||||
type AgentCancelCause =
|
||||
@@ -392,59 +464,97 @@ type AgentCancelCause =
|
||||
| { readonly kind: 'parent' }
|
||||
```
|
||||
|
||||
The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults.
|
||||
|
||||
```ts type-equiv
|
||||
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
|
||||
interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
/** The provider route and model this agent's requests use. */
|
||||
readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
|
||||
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
|
||||
* that turn's checkpoint.
|
||||
* Attached contexts share the same snapshot and ownership boundary. Invalid
|
||||
* input throws synchronously before notification or enqueue.
|
||||
* Queue an ordinary message as its own FIFO-ordered turn and wake the driver.
|
||||
* Content, resolved source, and attached contexts are detached, validated,
|
||||
* and frozen together; invalid input throws synchronously before notification
|
||||
* or enqueue.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - source, attached contexts, and durable model-hidden meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
followup(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Submit steering while the agent is `running`. An open turn records it at
|
||||
* the next steering checkpoint before a request or continuation decision;
|
||||
* policy may stop before another step. After turn close and its checkpoint,
|
||||
* any remainder is queued for a later turn; terminal `agent/turn-stop`,
|
||||
* cancellation, or disposal may discard it. Uses the same synchronous
|
||||
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
|
||||
* Queue an ordinary message without waking an idle driver. The item retains
|
||||
* FIFO order and is claimed only after another input wakes the driver. A lone
|
||||
* queued item leaves `whenIdle()` resolved.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - source, attached contexts, and durable model-hidden meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Submit steering into the running turn and request another step. An open turn
|
||||
* records it at the next steering checkpoint before a request or continuation
|
||||
* decision; policy may stop before another step. After turn close and its
|
||||
* checkpoint, any remainder is queued for a later turn; terminal
|
||||
* `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering
|
||||
* becomes a waking ordinary turn.
|
||||
* @param content - the steering content blocks.
|
||||
* @param options - source, attached contexts, and durable model-hidden meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Append detached model-facing context without running the model. An open-turn
|
||||
* injection joins at the current log position unless the current tool batch is
|
||||
* executing; then it waits FIFO until that batch settles and drains before turn
|
||||
* close even when interrupted. Idle injection uses a one-shot turn and durability
|
||||
* checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`.
|
||||
* executing; then it waits FIFO until that batch settles and drains before
|
||||
* turn close even when interrupted. Idle injection uses a one-shot turn and
|
||||
* durability checkpoint. Disposal awaits idle checkpoints; flush failures
|
||||
* report through `agent/error`. An omitted source defaults to
|
||||
* `{ kind: 'plugin', plugin: '' }`.
|
||||
* @param content - the injected context content blocks.
|
||||
* @param options - source and durable model-hidden meta.
|
||||
* @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void
|
||||
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Clear all queued and steering work, including items waiting to start, and
|
||||
* abort the active turn. An effective call first emits
|
||||
* `agent/cancel-requested` with the resolved typed cause. The first cause wins
|
||||
* for the active turn, and `whenIdle()` resolves after cancellation reaches
|
||||
* quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
|
||||
* and does not arm later work. The active turn snapshots and freezes the cause.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* Accept one fully specified input through the same snapshot and routing path
|
||||
* as the four intent-named helpers. `next-turn` targets the ordinary FIFO;
|
||||
* `next-step`/wakeup targets steering (falling back to an ordinary waking turn
|
||||
* while idle); and `next-step` without wakeup injects durable context without
|
||||
* running the model. Every field is mandatory and no source or routing default
|
||||
* is applied. Invalid input throws synchronously before notification, enqueue,
|
||||
* or append.
|
||||
* @param input - the resolved content, attribution, context, metadata, and routing facts.
|
||||
* @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable.
|
||||
*/
|
||||
cancel(cause?: AgentCancelCause): void
|
||||
send(input: ResolvedAgentInput): AgentMessageId
|
||||
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn. An effective call first emits `agent/cancel-requested` with the
|
||||
* resolved typed cause. The first cause wins for the active turn, and
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
|
||||
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
|
||||
* later work. The active turn snapshots and freezes the cause.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause?: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
@@ -460,7 +570,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above,
|
||||
|
||||
## Interception decisions
|
||||
|
||||
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
|
||||
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
@@ -470,8 +580,8 @@ interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/**
|
||||
* Model placement. Absent or `separate` records an independent
|
||||
* `context/message`; `prompt-prefix` prepends this context and a stable
|
||||
* Model placement. Absent or `separate` records an independent injected
|
||||
* `user/message`; `prompt-prefix` prepends this context and a stable
|
||||
* request delimiter to the same user-role message as its attached prompt.
|
||||
*/
|
||||
placement?: 'separate' | 'prompt-prefix'
|
||||
|
||||
@@ -69,7 +69,7 @@ interface GoalView extends GoalSnapshot {
|
||||
|
||||
## Durable changes
|
||||
|
||||
Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant.
|
||||
Every mutation is a round-zero goal-sourced `user/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant.
|
||||
|
||||
```ts type-equiv
|
||||
/** Full-snapshot goal mutation retained in a model-visible context event. */
|
||||
|
||||
@@ -25,7 +25,17 @@ interface SessionRecord {
|
||||
}
|
||||
```
|
||||
|
||||
`SessionSurfaceSnapshot` is one exact-read observation rather than a retained subscription. Its raw-log boundary and folded events come from the same live-preferred load.
|
||||
`SessionLogSnapshot` is the complete detached, replay-validated raw log used by resume preflight. `SessionSurfaceSnapshot` is one exact-read surface observation rather than a retained subscription.
|
||||
|
||||
```ts type-equiv
|
||||
/** One validated detached observation of a logical session's complete raw log. */
|
||||
interface SessionLogSnapshot {
|
||||
/** Cloned session header selected from the same observation as `events`. */
|
||||
session: SessionHeader
|
||||
/** Cloned contiguous raw events after persistence repair and replay validation. */
|
||||
events: SessionEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One atomic live-preferred observation of a session's current model surface. */
|
||||
|
||||
@@ -36,7 +36,7 @@ interface SessionReferenceCandidate {
|
||||
|
||||
## Prepared messages
|
||||
|
||||
Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call.
|
||||
Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call.
|
||||
|
||||
```ts type-equiv
|
||||
/** Message payload and the zero-or-one durable snapshot contexts bound to it. */
|
||||
|
||||
@@ -9,7 +9,13 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
|
||||
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
|
||||
|
||||
```ts type-equiv
|
||||
/** Shared payload for ordinary and steering prompt messages. */
|
||||
/**
|
||||
* Shared payload for user, injected-context, and steering prompt messages. A
|
||||
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
|
||||
* steering all project into the model transcript as verbatim user-role content;
|
||||
* they are told apart by `source` (a non-`user` kind marks injected context),
|
||||
* not by event type. `meta` carries durable model-hidden producer state.
|
||||
*/
|
||||
interface PromptMessageData {
|
||||
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
|
||||
content: ContentBlock[]
|
||||
@@ -17,6 +23,15 @@ interface PromptMessageData {
|
||||
source: MessageSource
|
||||
/** Present only when prompt-prefix contexts were baked into `content`. */
|
||||
envelope?: PromptMessageEnvelope
|
||||
/**
|
||||
* Opaque durable JSON state retained on the event but hidden from the model
|
||||
* projection. It is the intended channel for a future framing directive (a
|
||||
* producer declares the frame, a dedicated renderer applies it — see the
|
||||
* deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
meta?: JsonValue
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,29 +61,21 @@ interface SessionEventMap {
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
/**
|
||||
* A user-role message on the model-visible surface: a direct human prompt
|
||||
* (the queued message claimed for this turn), a synthetic `agent.inject()`
|
||||
* context (file-change notices, subdir AGENTS.md, skill content, cron
|
||||
* notifications, …), or an admitted goal continuation round. All three
|
||||
* project their `content` verbatim; `source` (with a non-`user` kind marking
|
||||
* injected context) is the only channel that tells them apart. An idle
|
||||
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
|
||||
*/
|
||||
'user/message': PromptMessageData
|
||||
/**
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, and its turn runs zero steps.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||
/**
|
||||
@@ -199,7 +206,7 @@ A proper discriminated union over `type` (not independent `type`/`data` unions),
|
||||
*
|
||||
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
|
||||
* they only exist on {@link SurfaceEventType} variants (`user/message`,
|
||||
* `assistant/message`, `tool/result`, `context/message`, `steering/message`).
|
||||
* `assistant/message`, `tool/result`, `steering/message`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
@@ -233,7 +240,7 @@ For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-emp
|
||||
|
||||
## Surface types
|
||||
|
||||
The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md).
|
||||
The four message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md).
|
||||
|
||||
### `SurfaceEventType` — the message-producing subset of event types
|
||||
|
||||
@@ -247,7 +254,6 @@ type SurfaceEventType =
|
||||
| 'user/message'
|
||||
| 'assistant/message'
|
||||
| 'tool/result'
|
||||
| 'context/message'
|
||||
| 'steering/message'
|
||||
```
|
||||
|
||||
@@ -258,7 +264,7 @@ type SurfaceEventType =
|
||||
* How a session event entered the ordered surface. Only valid on
|
||||
* {@link SurfaceEventType} events.
|
||||
*
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
|
||||
* messages.
|
||||
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
|
||||
* (inclusive) through `end` (inclusive) with this node. Both must exist as
|
||||
@@ -465,7 +471,7 @@ declare class Session {
|
||||
- `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata.
|
||||
- `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript.
|
||||
- `tool/result` → a user message carrying a `tool-result` block.
|
||||
- `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered.
|
||||
- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered.
|
||||
- `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata.
|
||||
|
||||
Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data.
|
||||
@@ -489,11 +495,12 @@ interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
/**
|
||||
* An out-of-band context injection (`agent.inject()`) made while the agent
|
||||
* was idle. The loop wraps the injected `context/message` in a one-shot turn
|
||||
* (`turn/start` → `context/message` → `turn/end`) so every event in the log
|
||||
* stays turn-enclosed — the durability/replay boundary is the turn, and a
|
||||
* bare event between turns would otherwise be indistinguishable from a crash
|
||||
* tail on reload.
|
||||
* was idle. The loop wraps the injected `user/message` (a non-`user` source,
|
||||
* plugin by default) in a one-shot turn (`turn/start` → `user/message` →
|
||||
* `turn/end`) so every event in the log stays turn-enclosed — the
|
||||
* durability/replay boundary is the turn, and a bare event between turns would
|
||||
* otherwise be indistinguishable from a crash tail on reload. The trigger's
|
||||
* `source` mirrors that message's producer.
|
||||
*/
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
@@ -542,13 +549,13 @@ interface TurnEndReasonMap {
|
||||
|
||||
## The turn-enclosure invariant
|
||||
|
||||
Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md).
|
||||
Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md).
|
||||
|
||||
## Plugin-contributed log-only events
|
||||
|
||||
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
|
||||
|
||||
The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
|
||||
The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
|
||||
|
||||
## Durability contract
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma
|
||||
|
||||
## Async state is not synchronous state
|
||||
|
||||
`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly.
|
||||
`agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly.
|
||||
|
||||
## Dispose must reach quiescence, not just request it
|
||||
|
||||
|
||||
@@ -8,22 +8,24 @@ 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:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`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) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../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:330`](../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), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`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:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
|
||||
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`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) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../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:463`](../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), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`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:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `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/ui/acp) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
|
||||
**dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。
|
||||
|
||||
> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items.
|
||||
> **Async state is not synchronous state** — `agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items.
|
||||
|
||||
**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。
|
||||
**异步状态不等同于同步瞬时状态**:调用 `agent.followup()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。
|
||||
|
||||
## ③ 测试政策清单
|
||||
|
||||
|
||||
@@ -674,11 +674,13 @@ flowchart TD
|
||||
pkg_tui --> pkg_agent
|
||||
pkg_tui --> pkg_agent_loop
|
||||
pkg_tui --> pkg_commands
|
||||
pkg_tui --> pkg_goal
|
||||
pkg_tui --> pkg_invariants
|
||||
pkg_tui --> pkg_llm
|
||||
pkg_tui --> pkg_llm_retry
|
||||
pkg_tui --> pkg_session
|
||||
pkg_tui --> pkg_session_persistence
|
||||
pkg_tui --> pkg_session_query
|
||||
pkg_tui --> pkg_session_reference
|
||||
pkg_tui --> pkg_session_title
|
||||
pkg_tui --> pkg_skill
|
||||
@@ -898,7 +900,7 @@ flowchart TD
|
||||
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
+25
-45
@@ -24,14 +24,13 @@ export type SurfaceEventType =
|
||||
| 'user/message'
|
||||
| 'assistant/message'
|
||||
| 'tool/result'
|
||||
| 'context/message'
|
||||
| 'steering/message'
|
||||
|
||||
/**
|
||||
* How a session event entered the ordered surface. Only valid on
|
||||
* {@link SurfaceEventType} events.
|
||||
*
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
|
||||
* messages.
|
||||
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
|
||||
* (inclusive) through `end` (inclusive) with this node. Both must exist as
|
||||
@@ -51,7 +50,7 @@ export type SurfaceOp =
|
||||
*
|
||||
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
|
||||
* they only exist on {@link SurfaceEventType} variants (`user/message`,
|
||||
* `assistant/message`, `tool/result`, `context/message`, `steering/message`).
|
||||
* `assistant/message`, `tool/result`, `steering/message`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
@@ -79,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}[T]
|
||||
```
|
||||
|
||||
Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:360`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts)
|
||||
Sources: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts)
|
||||
|
||||
## Events
|
||||
|
||||
@@ -151,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv
|
||||
|
||||
Types: [StreamChunk](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `assistant/message` — surface
|
||||
|
||||
@@ -167,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `compact/*`
|
||||
|
||||
@@ -221,33 +220,6 @@ Types: [ContentBlock](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
### `context/*`
|
||||
|
||||
#### `context/message` — surface
|
||||
|
||||
```ts persistence-catalog
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
meta?: JsonValue
|
||||
}
|
||||
```
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `hook/*`
|
||||
|
||||
#### `hook/invoked` — log-only
|
||||
@@ -357,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `request/*`
|
||||
|
||||
@@ -371,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `sandbox/*`
|
||||
|
||||
@@ -427,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages
|
||||
'steering/message': PromptMessageData & { turn: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `step/*`
|
||||
|
||||
@@ -438,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/
|
||||
'step/end': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `step/start` — log-only
|
||||
|
||||
@@ -447,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/
|
||||
'step/start': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `todo/*`
|
||||
|
||||
@@ -460,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/
|
||||
|
||||
Types: [TodoItem](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -477,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/
|
||||
|
||||
Types: [CallId](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/code-dispatch` — log-only
|
||||
|
||||
@@ -531,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c
|
||||
|
||||
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -549,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/
|
||||
|
||||
Types: [TurnEndReason](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -565,15 +537,23 @@ Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/
|
||||
|
||||
Types: [TurnTrigger](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
#### `user/message` — surface
|
||||
|
||||
```ts persistence-catalog
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
/**
|
||||
* A user-role message on the model-visible surface: a direct human prompt
|
||||
* (the queued message claimed for this turn), a synthetic `agent.inject()`
|
||||
* context (file-change notices, subdir AGENTS.md, skill content, cron
|
||||
* notifications, …), or an admitted goal continuation round. All three
|
||||
* project their `content` verbatim; `source` (with a non-`user` kind marking
|
||||
* injected context) is the only channel that tells them apart. An idle
|
||||
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
|
||||
*/
|
||||
'user/message': PromptMessageData
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts)
|
||||
@@ -23,12 +23,12 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
|
||||
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
|
||||
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
|
||||
| `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
|
||||
|
||||
@@ -22,7 +22,7 @@ flowchart TD
|
||||
normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]
|
||||
finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]
|
||||
final["<code>tools/result</code> synchronous notification<br/>frozen authoritative outcome"]
|
||||
context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]
|
||||
context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"]
|
||||
toolResult["Session event: <code>tool/result</code><br/>single model-facing outcome"]
|
||||
allResults["Tool batch settled<br/>recorded tool/result events complete"]
|
||||
presentResult["UI completed card<br/>presentResult(args, result)"]
|
||||
|
||||
@@ -140,11 +140,11 @@ const SCENARIOS: Scenario[] = [
|
||||
// Keyless, authored (like error-finish/cancel): deterministically forcing a
|
||||
// LIVE model to repeat one call three times is not a stable recording, so
|
||||
// the fixture scripts five identical todo_write calls and pins BOTH reminder
|
||||
// tiers (gentle at 3, detailed at 5) as context/message in transcript and log.
|
||||
// tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log.
|
||||
{ name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
|
||||
// Authored replay: a root AGENTS.md pins the session prefix, then a read in
|
||||
// nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
|
||||
// context/message. Both AGENTS.md fixtures are symlinks to a sibling
|
||||
// injected user/message. Both AGENTS.md fixtures are symlinks to a sibling
|
||||
// AGENTS.canonical.md, so this scenario also guards that discovery follows a
|
||||
// symlinked instruction file to its target's content. The scenario-specific
|
||||
// config keeps home/root discovery hermetic, and the resulting prefix needs
|
||||
@@ -220,7 +220,7 @@ const SCENARIOS: Scenario[] = [
|
||||
// tool/code-dispatch events. Each overlay composes and pins its own header class.
|
||||
{ name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
|
||||
// A nested fs dispatch inside run_code discovers workspace instructions. The
|
||||
// context/message must follow the outer result while retaining workspace
|
||||
// injected user/message must follow the outer result while retaining workspace
|
||||
// provenance, which proves Code Mode carries deferred tool context end to end.
|
||||
{
|
||||
name: 'code-mode-workspace-context',
|
||||
|
||||
+4
-4
@@ -688,7 +688,7 @@
|
||||
{"type":"turn/start","seq":686,"time":1783421455801,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"approval/policy","seq":687,"time":1783421455801,"data":{"policy":"never"}}
|
||||
{"type":"user/message","seq":688,"time":1783421455801,"data":{"content":[{"type":"text","text":"帮我创建一个 c.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":690,"time":1783421455802,"data":{"turn":4,"step":1}}
|
||||
{"type":"request/header-delta","seq":691,"time":1783421455802,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":["","Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation."]}}}
|
||||
{"type":"assistant/chunk","seq":692,"time":1783421456825,"data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -830,7 +830,7 @@
|
||||
{"type":"turn/start","seq":828,"time":1783421478599,"data":{"turn":5,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"bash/sandbox-mode","seq":829,"time":1783421478599,"data":{"mode":"workspace-write"}}
|
||||
{"type":"user/message","seq":830,"time":1783421478599,"data":{"content":[{"type":"text","text":"帮我创建一个 d.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":832,"time":1783421478600,"data":{"turn":5,"step":1}}
|
||||
{"type":"request/header-delta","seq":833,"time":1783421478600,"data":{"system":{"keepStart":12,"keepEnd":2,"insert":["Bash commands run under the \"workspace-write\" file sandbox."]}}}
|
||||
{"type":"assistant/chunk","seq":834,"time":1783421479489,"data":{"turn":5,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -1482,7 +1482,7 @@
|
||||
{"type":"turn/start","seq":1480,"time":1783421524030,"data":{"turn":7,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"approval/policy","seq":1481,"time":1783421524030,"data":{"policy":"ask"}}
|
||||
{"type":"user/message","seq":1482,"time":1783421524030,"data":{"content":[{"type":"text","text":"帮我在 ~ 创建一个 f.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":1484,"time":1783421524030,"data":{"turn":7,"step":1}}
|
||||
{"type":"request/header-delta","seq":1485,"time":1783421524030,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":[]}}}
|
||||
{"type":"assistant/chunk","seq":1486,"time":1783421524940,"data":{"turn":7,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -1946,7 +1946,7 @@
|
||||
{"type":"turn/start","seq":1944,"time":1783421552564,"data":{"turn":9,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"bash/sandbox-mode","seq":1945,"time":1783421552564,"data":{"mode":"danger-full-access"}}
|
||||
{"type":"user/message","seq":1946,"time":1783421552564,"data":{"content":[{"type":"text","text":"创建一个 h.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":1948,"time":1783421552564,"data":{"turn":9,"step":1}}
|
||||
{"type":"request/header-delta","seq":1949,"time":1783421552564,"data":{"system":{"keepStart":12,"keepEnd":0,"insert":["Bash commands run under the \"danger-full-access\" file sandbox."]}}}
|
||||
{"type":"assistant/chunk","seq":1950,"time":1783421553289,"data":{"turn":9,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}
|
||||
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
@@ -49,6 +49,6 @@
|
||||
{"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}}
|
||||
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
|
||||
{"type":"context/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}}
|
||||
{"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}}
|
||||
@@ -83,6 +83,7 @@ describe('ACP same-session goal snapshot', () => {
|
||||
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
|
||||
expect(calls).toEqual(['create_goal', 'get_goal'])
|
||||
const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
|
||||
&& event.data.source.round > 0
|
||||
? [event.data.source.round]
|
||||
: [])
|
||||
expect(rounds).toEqual([1, 2])
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}}
|
||||
{"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"<path>./nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}}
|
||||
{"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"Touch this file to discover the nested workspace instruction."}],"isError":false},"sourceEventSeqs":[85],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":88,"time":1784811336862,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -63,7 +63,7 @@
|
||||
{"type":"hook/invoked","seq":61,"time":1783352197968,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}}
|
||||
{"type":"hook/result","seq":62,"time":1783352197976,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.404540999999881}}
|
||||
{"type":"tool/result","seq":63,"time":1783352197976,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":65,"time":1783352197977,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":66,"time":1783352197977,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":67,"time":1783352198981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}}
|
||||
{"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}}
|
||||
{"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
{"type":"hook/invoked","seq":61,"time":1783352229622,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}}
|
||||
{"type":"hook/result","seq":62,"time":1783352229632,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":9.27664199999981}}
|
||||
{"type":"tool/result","seq":63,"time":1783352229632,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":65,"time":1783352229633,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":66,"time":1783352229633,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":67,"time":1783352230757,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}}
|
||||
{"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}}
|
||||
{"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
{"type":"sandbox/mode","seq":104,"time":1784518115842,"data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","seq":105,"time":1783962244624,"data":{"policy":"never"}}
|
||||
{"type":"user/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":108,"time":1783962244624,"data":{"turn":2,"step":1}}
|
||||
{"type":"request/header","seq":109,"time":1784000791271,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":110,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{"type":"tool/call","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":34,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[33],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
@@ -58,7 +58,7 @@
|
||||
{"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":57,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":60,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":61,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}
|
||||
{"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
agent.followup([{
|
||||
type: 'text',
|
||||
text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' '
|
||||
+ 'cordis event and logs every change with console.log. Reply "mounted" once done.',
|
||||
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
|
||||
})
|
||||
expect(resultText(mid)).toContain('dyn-')
|
||||
|
||||
agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }])
|
||||
agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const after = await ctx.tools.execute({
|
||||
@@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
|
||||
ctx = await cordisHarness()
|
||||
const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
agent.followup([{
|
||||
type: 'text',
|
||||
text: 'Give yourself a new tool: use cordis_mount to mount a plugin with '
|
||||
+ 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) '
|
||||
@@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
|
||||
ctx = await cordisHarness()
|
||||
const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
agent.followup([{
|
||||
type: 'text',
|
||||
text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls '
|
||||
+ 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with '
|
||||
@@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
|
||||
.flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text))
|
||||
expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true)
|
||||
|
||||
agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }])
|
||||
agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The consumer must have been parked by cordis itself: service gone,
|
||||
|
||||
@@ -311,7 +311,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
|
||||
ctx = await codeModeHarness(workdir)
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
agent.followup([{
|
||||
type: 'text',
|
||||
text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, '
|
||||
+ 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), '
|
||||
@@ -363,7 +363,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
|
||||
handle.agent.send([{
|
||||
handle.agent.followup([{
|
||||
type: 'text',
|
||||
text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
|
||||
}])
|
||||
@@ -372,7 +372,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
|
||||
const events: SessionEvent[] = [...handle.agent.session.events]
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
|
||||
const outerResult = events.find(event => event.type === 'tool/result')
|
||||
const workspaceContext = events.find(event => event.type === 'context/message'
|
||||
const workspaceContext = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& typeof event.data.meta === 'object'
|
||||
&& event.data.meta !== null
|
||||
&& !Array.isArray(event.data.meta)
|
||||
|
||||
@@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test
|
||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
agent.followup([{
|
||||
type: 'text',
|
||||
text: 'In the current directory, `node add.test.js` fails because add.js has a bug. '
|
||||
+ 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. '
|
||||
|
||||
@@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
agent.followup([{
|
||||
type: 'text',
|
||||
text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a '
|
||||
+ 'time using cat (a separate bash command for each). After reading all four, tell me how '
|
||||
|
||||
@@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas
|
||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
|
||||
agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
|
||||
@@ -222,9 +222,14 @@ describe('headless stream-json snapshots', () => {
|
||||
const records = parseJsonl(logs[0]?.content ?? '')
|
||||
const calls = records.filter(record => record.type === 'tool/call')
|
||||
.map(record => (record.data as JsonObject | undefined)?.name)
|
||||
expect(calls).toEqual(['create_goal', 'get_goal'])
|
||||
expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal'])
|
||||
const probeResult = records.find(record => record.type === 'tool/result'
|
||||
&& (record.data as JsonObject | undefined)?.callId === 'call_goal_probe')
|
||||
const probeData = probeResult?.data as JsonObject | undefined
|
||||
expect(probeData?.isError).toBe(true)
|
||||
expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
|
||||
const goalChanges = records.filter((record) => {
|
||||
if (record.type !== 'context/message') return false
|
||||
if (record.type !== 'user/message') return false
|
||||
const data = record.data as JsonObject | undefined
|
||||
const meta = data?.meta as JsonObject | undefined
|
||||
return meta?.kind === 'goal/change'
|
||||
|
||||
@@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
|
||||
sessionId: SESSION_ID,
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})).agent
|
||||
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
|
||||
first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
|
||||
await waitForIdle(ctx, first)
|
||||
await ctx.fiber.dispose()
|
||||
ctx = undefined
|
||||
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
|
||||
// The prior user turn is in the rehydrated log before the model is asked.
|
||||
expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)
|
||||
|
||||
resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }])
|
||||
resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }])
|
||||
await waitForIdle(ctx, resumed)
|
||||
|
||||
// The model recalls it — only possible from the resumed history.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"steps": [
|
||||
{
|
||||
"op": "prompt",
|
||||
"text": "Create a durable goal to finish the snapshot proof, then inspect it."
|
||||
"text": "Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_goal_probe", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_probe", "name": "update_goal", "arguments": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 15, "outputTokens": 6 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal to","messageSeqs":[1],"source":{"kind":"fallback"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[1],"source":{"kind":"fallback"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15,"outputTokens":6}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[11],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
|
||||
{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":43,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
|
||||
{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":100,"outputTokens":20}}
|
||||
@@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a
|
||||
ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text:
|
||||
agent.followup([{ type: 'text', text:
|
||||
'Use the todo_write tool to record a plan of exactly two steps: first '
|
||||
+ '"inspect the failing test" (in_progress), then "apply the fix" (pending). '
|
||||
+ 'Send both in one todo_write call, then reply with the single word DONE.' }])
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@deepseek-ai/dsh-jsonrpc": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm-replay": "workspace:*",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:*",
|
||||
"@deepseek-ai/dsh-lsp-local": "workspace:*",
|
||||
|
||||
@@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
|
||||
dsh --resume <prior-session-id>
|
||||
```
|
||||
|
||||
The TUI prints this exact command on exit and lists it under `/resume`, so resuming is copy-paste. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero.
|
||||
`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately.
|
||||
|
||||
## Code Mode
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
# The smoke's log inspection reads plain `.jsonl`; keep the scripted
|
||||
# fixture uncompressed like the other snapshot-facing configs.
|
||||
persistenceCompression: none
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
resumeCommand: 'dsh --resume {session}'
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'scripted TUI ready.'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
|
||||
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
|
||||
@@ -44,6 +46,31 @@ function seedWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
|
||||
async function seedResumeSession(cwd: string): Promise<void> {
|
||||
const sessionCwd = await realpath(cwd)
|
||||
const id = SessionId('resume-target')
|
||||
const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd }
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: 1_700_000_000_002, data: { content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } },
|
||||
{ type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'persisted answer' }], provenance: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } },
|
||||
{ type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
{ type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } },
|
||||
{ type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none')
|
||||
await mkdir(dirname(file), { recursive: true })
|
||||
await writeFile(file, [
|
||||
JSON.stringify(toHeaderLine(meta)),
|
||||
...events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
/** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */
|
||||
async function readLoggedSystemPrompt(cwd: string): Promise<string> {
|
||||
const sessionsDir = join(cwd, '.sessions')
|
||||
@@ -233,6 +260,27 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||
})
|
||||
|
||||
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
it('exec-replaces the TUI for /resume and restores the same session state', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh in-place resume',
|
||||
tempDirPrefix: 'dsh-in-place-resume-',
|
||||
binScript: dshBinScript,
|
||||
configArgs: [scriptedConfigPath],
|
||||
prepare: seedResumeSession,
|
||||
actions: [
|
||||
{ waitFor: 'scripted TUI ready.', send: '/resume\r' },
|
||||
{ waitFor: 'Resume selector design', send: 'Resume selector design' },
|
||||
{ waitFor: '⌕ Resume selector design', send: '\r' },
|
||||
{ waitFor: 'Preserve restored state', send: '/exit\r' },
|
||||
],
|
||||
})
|
||||
const released = output.indexOf('\u001B[?2004l')
|
||||
const restored = output.indexOf('Resume selector design — DeepSeek Harness')
|
||||
expect(released).toBeGreaterThanOrEqual(0)
|
||||
expect(restored).toBeGreaterThan(released)
|
||||
expect(output).toContain('Preserve restored state')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots the shipped default config with no arguments and no personal overlay', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh default boot',
|
||||
|
||||
@@ -341,7 +341,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
}
|
||||
expect(exit.seq).toBeLessThan(afterExit.seq)
|
||||
expect(afterExit.data.header.system).not.toContain('Snapshot plan mode instructions.')
|
||||
expect(events.filter(event => event.type === 'context/message').map(event => event.data.content))
|
||||
expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
|
||||
.toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
|
||||
}
|
||||
expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true)
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => {
|
||||
const location = ctx.sessionPersistence.locate(agent.session.header)
|
||||
expect(location?.kind).toBe('jsonl')
|
||||
|
||||
agent.send([{ type: 'text', text: 'inspect the current session' }])
|
||||
agent.followup([{ type: 'text', text: 'inspect the current session' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = findEvent(events(agent), 'tool/result')
|
||||
@@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
|
||||
agent.followup([{ type: 'text', text: 'run echo integration-ok' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
@@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run exit 9' }])
|
||||
agent.followup([{ type: 'text', text: 'run exit 9' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const toolResult = findEvent(events(agent), 'tool/result')
|
||||
@@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
|
||||
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
@@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
@@ -188,17 +188,19 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable context/message into the owning agent's session (settlement may
|
||||
// race turn end, so poll for it).
|
||||
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
|
||||
const notice = findEvent(events(agent), 'context/message')
|
||||
// durable plugin-sourced user/message into the owning agent's session
|
||||
// (settlement may race turn end, so poll for it).
|
||||
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
await pollUntil(() => events(agent).some(isNotice))
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
agent.send([{ type: 'text', text: 'collect it' }])
|
||||
agent.followup([{ type: 'text', text: 'collect it' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.isError).toBe(false)
|
||||
|
||||
@@ -76,7 +76,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
})
|
||||
}
|
||||
if (turn % 9 === 4) {
|
||||
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
}
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
const withTool = turn % 5 === 2
|
||||
|
||||
@@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
export interface UserMessageNode {
|
||||
kind: 'user'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
@@ -50,6 +52,8 @@ export interface UserMessageNode {
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
|
||||
time: number
|
||||
turn: number
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
@@ -63,6 +67,8 @@ export interface AssistantMessageNode {
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
@@ -72,6 +78,8 @@ export interface SteeringMessageNode {
|
||||
export interface ContextMessageNode {
|
||||
kind: 'context'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
meta?: unknown
|
||||
@@ -81,9 +89,13 @@ export interface ContextMessageNode {
|
||||
export interface ToolResultNode {
|
||||
kind: 'tool-result'
|
||||
seq: number
|
||||
/** Unix epoch ms from the tool/result session event. */
|
||||
time: number
|
||||
callId: string
|
||||
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
|
||||
call: { name: string; argsRaw: string } | null
|
||||
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
|
||||
callTime: number | null
|
||||
content: readonly ContentBlock[]
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
@@ -98,6 +110,8 @@ export interface ToolResultNode {
|
||||
export interface UnknownSurfaceNode {
|
||||
kind: 'unknown'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event when known. */
|
||||
time: number
|
||||
type: string
|
||||
data: unknown
|
||||
}
|
||||
@@ -118,6 +132,8 @@ export interface RunningToolCall {
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms when the tool/call event was logged. */
|
||||
time: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface CallIndexEntry {
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms of the tool/call event. */
|
||||
time: number
|
||||
/** Wire view riding the tool/call (envelope-level; never inside the event). */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
@@ -38,24 +40,37 @@ function materializeNode(
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
|
||||
// Injected context (plugin/goal source) folds to a context node, not a
|
||||
// user message; only a direct human prompt is a user node.
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
|
||||
case 'context/message':
|
||||
return {
|
||||
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: String(event.data.callId),
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
@@ -63,11 +78,14 @@ function materializeNode(
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
|
||||
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
|
||||
surface-eligible types, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +204,7 @@ export class FoldAdapter {
|
||||
if (event.type !== 'tool/call') return
|
||||
this.callIdx.set(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,
|
||||
})
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
|
||||
@@ -95,9 +95,10 @@ export class SessionsService {
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open}. Projection validates it against the live list
|
||||
* instead of destructively pruning, so a selection survives transient list
|
||||
* states (reconnect re-pull) and resurfaces when its session returns.
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
@@ -116,7 +117,7 @@ export class SessionsService {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
@@ -137,7 +138,7 @@ export class SessionsService {
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere (the sole selection write path).
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
@@ -148,6 +149,17 @@ export class SessionsService {
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state. Wipes the persisted selection too — a reload stays on empty until
|
||||
* the user opens or starts a session. Staging holds the previous occupant
|
||||
* across the blank (same masked-gap rule as a transient list miss).
|
||||
*/
|
||||
clear(): void {
|
||||
this.selection.set({})
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
@@ -159,6 +171,27 @@ export class SessionsService {
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace folder under the host process cwd and a session in it.
|
||||
* Name is a single path segment (no separators); the host mkdir runs inside
|
||||
* session.create. Caller opens the returned id when it wants the session staged.
|
||||
* @param name - workspace folder basename.
|
||||
* @returns the new session id.
|
||||
*/
|
||||
async createWorkspace(name: string): Promise<SessionId> {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
|
||||
if (/[/\\]/.test(trimmed)) {
|
||||
throw new Error('sessions.createWorkspace: name must not contain path separators')
|
||||
}
|
||||
const { result } = await this.api.host.describe({})
|
||||
if (!result.ok) {
|
||||
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
|
||||
return this.create({ cwd: `${hostCwd}/${trimmed}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
|
||||
@@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
case 'tool/call': {
|
||||
this.openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
turn: event.data.turn, step: event.data.step, time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
this.callsRev++
|
||||
@@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (visible) {
|
||||
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
|
||||
this.frozenNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: this.partial.turn, step: this.partial.step,
|
||||
blocks, interrupted: true,
|
||||
})
|
||||
this.frozenRev++
|
||||
@@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.callsRev++
|
||||
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
|
||||
this.frozenNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('FoldAdapter', () => {
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
|
||||
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
]
|
||||
|
||||
@@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
|
||||
})
|
||||
|
||||
it('clear() blanks list.current and the persisted selection', async () => {
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
removeItem: (k: string) => { storage.delete(k) },
|
||||
clear: () => { storage.clear() },
|
||||
})
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(storage.get('dsh.sessions.current')).toContain('s1')
|
||||
b.svc.clear()
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
// Persisted wipe: a fresh service with the same storage stays on empty.
|
||||
const again = bench()
|
||||
await feedList(again, [{ id: 's1' }])
|
||||
expect(again.svc.list.getSnapshot().current).toBeUndefined()
|
||||
})
|
||||
|
||||
it('masks (not destroys) the selection while its session is off the list', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
@@ -277,6 +297,27 @@ describe('create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspace', () => {
|
||||
it('joins host.describe cwd with the name and creates there', async () => {
|
||||
const b = bench()
|
||||
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
|
||||
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
|
||||
})
|
||||
|
||||
it('rejects empty names and path separators; surfaces describe failures', async () => {
|
||||
const b = bench()
|
||||
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
|
||||
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
|
||||
b.api.onDescribe = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
|
||||
} as never)
|
||||
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverage tails (branch duals)', () => {
|
||||
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -160,6 +160,10 @@ export function apply(ctx: Context): void {
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation.startSession(opts)
|
||||
},
|
||||
createWorkspaceSession: async (name) => {
|
||||
const id = await sessions.createWorkspace(name)
|
||||
sessions.open(id)
|
||||
},
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
@@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyStateInjected {
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
/**
|
||||
* Create a workspace folder under the host cwd, mint a session there, and
|
||||
* open it (Create-new modal success path).
|
||||
*/
|
||||
createWorkspaceSession(name: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* NEW SESSION hero: headline over the shared InputBar card, centered in the
|
||||
conversation column. The card is the same component as the composer —
|
||||
only positioning lives here. */
|
||||
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
|
||||
above the shared InputBar card. The input itself is InputBar — only stack
|
||||
geometry and the chip live here. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
@@ -11,58 +11,154 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* figma hero group 34:10409: headline block sits 36px above the input card. */
|
||||
.card {
|
||||
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
align-items: stretch;
|
||||
/* figma 75:8208: 12 between title block / workspace / card. */
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: 800px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
|
||||
keeps 36px below the headline before the flex gap. */
|
||||
.headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* figma 34:10412/10413: brand-blue vector. */
|
||||
/* figma fish fill rides business blue. */
|
||||
.fish {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.picker {
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
|
||||
centered on this block so it stays under the picker + InputBar together. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
|
||||
.glow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
width: calc(100% * 1051 / 776);
|
||||
aspect-ratio: 1051 / 468;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.body > :not(.glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
|
||||
right-hand submenu) paints above the InputBar card. */
|
||||
.body > .workspaceRow {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
/* figma 75:8208 workspace row: px 8 above the card. */
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.select,
|
||||
.customInput {
|
||||
max-width: 320px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
|
||||
.workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.customInput {
|
||||
width: 320px;
|
||||
outline: none;
|
||||
.workspace:hover,
|
||||
.workspace[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.customInput:focus {
|
||||
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
.folder {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.workspaceLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Workspace menu width tracks the longest basename in the Figma frame. */
|
||||
.workspaceMenu :global([role='menu']) {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
|
||||
.modalInput {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.modalInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.modalInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.modalInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.modalError {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
@@ -1,21 +1,37 @@
|
||||
// EmptyState (figma NEW SESSION screen): centered hero card built around the
|
||||
// SAME InputBar component the resident composer uses (the empty→content
|
||||
// transition is one component changing position, never a swap). Project
|
||||
// picker: cwd set derived in-component from the standard useSessions hook
|
||||
// (subscription is the framework's, derivation is a pure function — design
|
||||
// §6) plus a free-form new-directory input; submit runs the startSession
|
||||
// chain (create → open → send) in one service call.
|
||||
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
|
||||
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
|
||||
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
|
||||
// composer uses (empty→content is a position move, never a swap). Project
|
||||
// options derive in-component from useSessions; Create new runs
|
||||
// createWorkspaceSession (host mkdir + session.create + open).
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
FishLogo,
|
||||
IconChevronDownOutline14,
|
||||
IconFolderClose16,
|
||||
IconFolderOpen16,
|
||||
IconPlusOutline16,
|
||||
Menu,
|
||||
Modal,
|
||||
type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
|
||||
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
|
||||
const NEW_DIR = '::new-directory'
|
||||
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
|
||||
const NEW_WORKSPACE = '::new-workspace'
|
||||
/** Submenu: path modal (figma 451:18655 copy). */
|
||||
const USE_EXISTING = '::use-existing'
|
||||
/** Submenu: create-workspace modal → mkdir + default session. */
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
/** Which full-page dialog is open (null = none). */
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
|
||||
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||
export type EmptyStateProps = EmptyStateSlotProps
|
||||
@@ -30,16 +46,30 @@ function deriveCwds(state: SessionListState): readonly string[] {
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
|
||||
function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
const [cwd, setCwd] = useState<string>('')
|
||||
const [custom, setCustom] = useState(false)
|
||||
const [cwd, setCwd] = useState('')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [modalKind, setModalKind] = useState<ModalKind>(null)
|
||||
const [pathDraft, setPathDraft] = useState('')
|
||||
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<InputBarError | null>(null)
|
||||
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
const text = draft.trim()
|
||||
@@ -58,62 +88,218 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
const picker = (
|
||||
<div className={css.picker}>
|
||||
{custom
|
||||
? (
|
||||
<input
|
||||
className={css.customInput}
|
||||
value={cwd}
|
||||
autoFocus
|
||||
placeholder="目录路径,如 /home/me/proj"
|
||||
onChange={(e) => { setCwd(e.target.value) }}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<select
|
||||
className={css.select}
|
||||
value={cwd}
|
||||
aria-label="项目目录"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === NEW_DIR) {
|
||||
setCustom(true)
|
||||
setCwd('')
|
||||
} else {
|
||||
setCwd(e.target.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">默认目录</option>
|
||||
{cwds.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
<option value={NEW_DIR}>新目录…</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const items: MenuEntry[] = [
|
||||
...cwds.map(c => ({
|
||||
id: c,
|
||||
label: workspaceLabel(c),
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
})),
|
||||
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
|
||||
{
|
||||
id: NEW_WORKSPACE,
|
||||
label: 'New Workspace',
|
||||
icon: <IconPlusOutline16 size={16} />,
|
||||
submenu: [
|
||||
{ id: USE_EXISTING, label: 'Use a existing folder' },
|
||||
{ id: CREATE_NEW, label: 'Create new' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const openPathModal = (): void => {
|
||||
setPathDraft(cwd)
|
||||
setModalError(null)
|
||||
setModalKind('path')
|
||||
}
|
||||
|
||||
const openCreateModal = (): void => {
|
||||
setWorkspaceName('New WorkSpace')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
}
|
||||
|
||||
const confirmPath = (): void => {
|
||||
const next = pathDraft.trim()
|
||||
if (next === '') return
|
||||
setCwd(next)
|
||||
setModalKind(null)
|
||||
}
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
setModalError(null)
|
||||
createWorkspaceSession(workspaceName)
|
||||
.catch((reason: unknown) => {
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setCreating(false)
|
||||
})
|
||||
// Success swaps this slot out for the new session body — no local cleanup.
|
||||
}
|
||||
|
||||
const modalBusy = creating
|
||||
const isPath = modalKind === 'path'
|
||||
const isCreate = modalKind === 'create'
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.card}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34x25 leading the headline, gap 10. */}
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build"
|
||||
accessory={picker}
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className={css.workspaceRow}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
{...(cwd !== '' ? { selectedId: cwd } : {})}
|
||||
items={items}
|
||||
side="top"
|
||||
className={css.workspaceMenu!}
|
||||
onSelect={(id) => {
|
||||
if (id === USE_EXISTING) {
|
||||
setMenuOpen(false)
|
||||
openPathModal()
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
setMenuOpen(false)
|
||||
openCreateModal()
|
||||
return
|
||||
}
|
||||
setCwd(id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label="项目目录"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => { setMenuOpen(!menuOpen) }}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build, enter for / commands"
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={isPath}
|
||||
onClose={closeModal}
|
||||
title="Enter an existing folder path"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={pathDraft.trim() === ''}
|
||||
onClick={confirmPath}
|
||||
>
|
||||
Open Folder
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={pathDraft}
|
||||
aria-label="Folder path"
|
||||
autoFocus
|
||||
placeholder="ex. User/Documents/Harness/Space"
|
||||
onChange={(e) => { setPathDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmPath()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isCreate}
|
||||
onClose={closeModal}
|
||||
title="Create new workspace"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={modalBusy || workspaceName.trim() === ''}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={modalBusy}
|
||||
onChange={(e) => { setWorkspaceName(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the
|
||||
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
||||
viewport bottom inside the centered message column; textarea on top, action
|
||||
row below, one primary circle button bottom-right. Input width rides the
|
||||
column (776 is a cap, not a fixed size — layout rule: the box shrinks with
|
||||
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
|
||||
the center column keeping its padding). Hero variant = the same card
|
||||
centered in the empty state; the transition between the two is a position
|
||||
move of one component. */
|
||||
@@ -10,8 +10,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
|
||||
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
padding: 8px 32px 12px;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: 800px;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
@@ -34,10 +34,12 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* figma Input 34:11458: 12px between the text area and the button row. */
|
||||
/* figma Input 75:8208: 12px between the text area and the button row; 10px
|
||||
top pad on the card before .InputText. */
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: 800px;
|
||||
padding-top: 10px;
|
||||
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
|
||||
the input border is one notch weaker than buttons) — exactly the
|
||||
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
|
||||
@@ -49,11 +51,6 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* New-session state rounds up (figma: r24 and a taller box). */
|
||||
.hero .card {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.accessory {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,7 +82,8 @@
|
||||
|
||||
.input,
|
||||
.mirror {
|
||||
padding: 12px 16px 0;
|
||||
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
|
||||
padding: 4px 12px 0 16px;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: pre-wrap;
|
||||
@@ -108,23 +106,98 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
|
||||
min-height: 60px;
|
||||
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
|
||||
min-height: 52px;
|
||||
max-height: 336px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero .mirror {
|
||||
/* New-session box is taller at rest (figma 118px input area). */
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */
|
||||
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
|
||||
(figma Input_Bottom chrome). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10px 10px 12px;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 10px 10px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tools,
|
||||
.modes,
|
||||
.trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
|
||||
.tools {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modes {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
flex: none;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
|
||||
.add {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-selector);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Plan / Read-only / model — native <select>, chip-like closed chrome
|
||||
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
|
||||
.select {
|
||||
max-width: 220px;
|
||||
height: 28px;
|
||||
padding: 0 20px 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
background-size: 12px 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.select:hover:not(:disabled) {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
|
||||
@@ -133,6 +206,7 @@
|
||||
.primary {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
// position move of this component, never a swap (layout ruling). Running
|
||||
// LOCKS the input: textarea disabled with the draft visible, stop is the only
|
||||
// action; the turn ending re-enables and refocuses.
|
||||
//
|
||||
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
|
||||
// local native <select> state, no host wiring.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
|
||||
@@ -24,13 +28,33 @@ export interface InputBarProps {
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
|
||||
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS: readonly SelectOption[] = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
]
|
||||
|
||||
const READONLY_OPTIONS: readonly SelectOption[] = [
|
||||
{ id: 'readonly', label: 'Read-only' },
|
||||
{ id: 'readwrite', label: 'Read-write' },
|
||||
]
|
||||
|
||||
const MODEL_OPTIONS: readonly SelectOption[] = [
|
||||
{ id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' },
|
||||
{ id: 'v4-pro', label: 'DeepSeek-V4-Pro' },
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
@@ -48,6 +72,11 @@ export function InputBar({
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Placeholder chrome: selection is local until plan/mode/model seams land.
|
||||
const [planId, setPlanId] = useState('plan')
|
||||
const [readonlyId, setReadonlyId] = useState('readonly')
|
||||
const [modelId, setModelId] = useState('v4-pro-high')
|
||||
|
||||
// Locked while running: the browser drops keystrokes AND focus on a disabled
|
||||
// textarea — no sending mid-turn, stop or wait.
|
||||
const locked = disabled || running
|
||||
@@ -88,6 +117,25 @@ export function InputBar({
|
||||
if (!empty && !disabled) onSend('queue')
|
||||
}
|
||||
|
||||
const renderSelect = (
|
||||
aria: string,
|
||||
value: string,
|
||||
options: readonly SelectOption[],
|
||||
onPick: (id: string) => void,
|
||||
): ReactNode => (
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label={aria}
|
||||
value={value}
|
||||
disabled={locked}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => { onPick(e.target.value) }}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{error !== null && (
|
||||
@@ -116,25 +164,44 @@ export function InputBar({
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
disabled={!running && (empty || disabled)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{running ? (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className={css.tools}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label="添加"
|
||||
title="添加"
|
||||
disabled={locked}
|
||||
onMouseDown={keepFocus}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
<div className={css.modes}>
|
||||
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
|
||||
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
disabled={!running && (empty || disabled)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{running ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ async function bench() {
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
@@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
|
||||
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession'])
|
||||
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
b.sessionsFake.open.mockClear()
|
||||
await injected.createWorkspaceSession('Fresh')
|
||||
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
})
|
||||
|
||||
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ afterEach(cleanup)
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('deriveStats', () => {
|
||||
|
||||
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [],
|
||||
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
@@ -112,8 +112,9 @@ describe('bash sample row', () => {
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, callId,
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
|
||||
callTime: 2_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
||||
turn: 1, step: 1, callView: null, ...over,
|
||||
turn: 1, step: 1, time: 1_000, callView: null, ...over,
|
||||
})
|
||||
|
||||
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, callId: 'c1',
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -54,18 +54,19 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
|
||||
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
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,
|
||||
})
|
||||
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
|
||||
|
||||
@@ -62,8 +62,9 @@ describe('tails', () => {
|
||||
|
||||
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 2, callId: 'c5',
|
||||
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,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
@@ -77,8 +78,9 @@ describe('tails', () => {
|
||||
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, callId: 'c1',
|
||||
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,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user