diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index d01c01c55a..3d17310d05 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md -2026-07-16-durable-per-step-time-context.md: e1a5c65894913ad93f46db8ae45e5ef5ead215f3 -2026-07-16-durable-per-step-time-context.zh.md: 129920a02dc21a91ddc92de3d920ddad24968656 +2026-07-16-durable-per-step-time-context.md: d7f950ab8f669282ec102fb02ac8399613ec806b +2026-07-16-durable-per-step-time-context.zh.md: 0eff29b14f7aaa13f4cef2bacd57b36d30dea6e5 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index 6c80158824..d7f950ab8f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -6,74 +6,69 @@ English | [中文](2026-07-16-durable-per-step-time-context.zh.md) ## Problem -A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. +A request-only clock can tell the model the current time, but replacing that value in the system prompt erases the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. -A process-local refresh cache makes displayed time depend on state that cannot survive resume or be reconstructed from the durable session. Durable interval scheduling can reduce append frequency without introducing that hidden state. - -Local calendar work also needs to distinguish two owned facts: the immutable zone captured by the Session and the zone attached to each browser-originated request. Process state or a mutable connection default cannot represent travel, concurrent tabs, or old headerless Sessions without silently reinterpreting a request. +A process-local refresh cache makes displayed time depend on state that cannot survive resume. Browser-originated natural language also needs a request-owned zone: a server process zone cannot infer the user's locality, while a mutable Session or connection default lets travel or concurrent tabs reinterpret another prompt. ## Decision -`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service. Default compositions leave its disclosure and token cost disabled; the explicit Schedule Web overlay mounts it because local `at` interpretation needs request-zone context. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. Default compositions leave its disclosure and token cost disabled; the Schedule Web overlay mounts it so the model can interpret otherwise-unqualified dates and times in the browser zone attached to the current request. -The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a request step and a reading is due, time-context derives client zones from that decision's final messages plus user-rpc messages already entered in the open turn, then appends one reading to the decision. Steering inserted after AgentLoop claims the current batch keeps ordinary next-step ownership and receives a new reading when that step enters. +The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters and a reading is due, it combines that decision's final messages with durable user messages already in the open turn, derives browser-zone provenance from exact `user-rpc` sources, and appends one reading to the decision. Rejection, listener failure, or an already-aborted signal records nothing. Steering claimed after the current batch keeps ordinary next-step ownership and receives a fresh reading when that step enters. -An entering step appends its returned messages followed by the time reading after `step/start`, before request derivation. A first-step decision rewritten to empty opens no request, while an empty tool continuation can still enter a later step without a new reading and reuse existing history. Rejection, failure, or cancellation before `step/start` appends nothing. Disposal prevents an in-flight listener from contributing after it wins, without adding inbox state or an AgentLoop lifecycle path. +Each Web prompt samples the browser's IANA zone. The Host validates and canonicalizes it before binding it to the exact durable user-message source. One unique zone in the open turn resolves the request; multiple zones produce a sorted `mixed` result; no zone is `unavailable`. A resolved request tells the model to interpret unqualified dates and times in that zone. Mixed or unavailable provenance tells it to ask the user to clarify. -Each reading has the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`; both the invariant companion and Schedule fail closed if that shape or equality drifts. The immutable `SessionHeader.timeZone` and each original user-rpc message's `clientTimeZone` remain the only machine-readable owners. Time-context renders those facts for the model, while Schedule derives directly from the same header and current-turn sources instead of consuming a copy. The rendered clock uses the Session zone when available. A headerless Session uses the configured fallback, or the Node process zone resolved once at plugin load when config is omitted, while still reporting the Session zone as `unavailable`. Every explicit or Session-owned IANA zone is validated through `Intl.DateTimeFormat`. +This message-bound provenance is not copied to `SessionHeader`, a connection default, or Schedule state. Time-context owns model guidance only. A tool accepting local calendar fields must still make its own explicit boundary; Schedule therefore requires `time_zone` rather than importing this plugin's reading ([decision](../simplification/2026-08-09-explicit-schedule-time-zone.md)). -The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every entered request step. A positive value scans the raw session events for the most recent `user/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. +The resolved browser zone also formats the reading's timestamp. Mixed or unavailable requests use the configured `timeZone` fallback, or the Node process zone resolved once at plugin load when config is omitted, while retaining the clarify policy. Every fallback is validated through `Intl.DateTimeFormat`. + +Each reading uses the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`. The invariant companion checks the snapshot shape, re-derives current-turn browser provenance from the original user-rpc messages, and validates the rendered timestamp zone and elapsed baseline. + +The optional `refreshIntervalMs` config is a non-negative safe integer. Omission or `0` injects on every eligible entered step. A positive value scans raw Session events for the latest plugin reading and injects when none exists, wall time moved backward, or the event is old enough. The event timestamp governs after compaction and resume without a process-local cache. The Schedule Web overlay omits the interval so every request step gets current browser guidance. ### Text and elapsed baselines -An injected first-step reading is: +A resolved first-step reading is: ```text -Time sampled while preparing turn , step 1: -Session time zone: . -Client time zone for this request: . +Time sampled while preparing turn , step 1: +Browser time zone for this request: . Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: . ``` -The baseline is the latest durable preceding user, assistant, or tool-result message. The prompt entering the same proposed step has not been appended yet; the first request in a new Session therefore reports `unavailable`. Existing durable history supplies the baseline on later turns. +Mixed and unavailable variants replace the second line with an instruction to ask for clarification. The baseline is the latest durable preceding user, assistant, or tool-result message. The prompt proposed for this step has not been appended yet; a new Session can therefore report `unavailable`. -An injected later-step reading is: +A later-step reading changes the first line's step number and ends with: ```text -Time sampled while preparing turn , step : -Session time zone: . -Client time zone for this request: . Elapsed since the preceding step context: . ``` -Their baseline is the durable event timestamp of the preceding time-context message in the same turn. If interval suppression leaves no earlier same-turn reading, the duration is `unavailable`. Duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. The explicit turn and step make every retained reading attributable to its historical preparation attempt after later turns append more context. +That baseline is the preceding time-context event in the open turn. Missing baselines report `unavailable`; duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. -### Durability and request reconstruction +### Durability and reconstruction -Each reading remains a normal surface node until compaction shadows it; positive interval scheduling never removes existing readings. A later request therefore sees the cumulative unshadowed readings that affected earlier preparation and steps, rather than a system-prompt value rewritten in place. The simple source identifies the reading without duplicating the Session or request-zone facts that Schedule can derive from their original durable owners. +An entered step appends its returned messages followed by the time reading after `step/start`, before request derivation. A later preparation failure can leave the reading in history because it records entry, not successful transmission. Each reading remains a normal surface node until compaction shadows it. A positive interval can let a later request reuse existing history without adding a fresh reading. -The plugin does not add a system-prompt section. `request/header` contains no time-context text, and request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because interval suppression can enter a request without appending a reading, while a failure after step entry may retain a reading without transmitting a request. A failure before step entry retains none. - -## Testing - -Unit and real-loop tests pin formatting, Session/fallback display zones, unique/mixed/missing client-zone derivation, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, post-claim steering ownership, empty suppression, cancellation, in-flight disposal, simple source validation, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader with the Headless composition, drives two ordered one-shot turns, and verifies the persisted plugin-attributed messages externally; the Schedule Web scenario verifies the same source facts through the assembled browser path. +The plugin contributes nothing to system-prompt assembly or `request/header`. Request reconstruction obtains the complete durable surface prefix at each `step/start`, so historical requests recover the exact time and browser policy the model saw. ## Alternatives considered -- **Keep the dynamic system-prompt section and process-local refresh cache** — rejected because replacement erases earlier readings, cache state is not replayable, and a frozen request envelope would make the value stale for an entire loop instance. -- **Replace the preceding context surface node** — rejected because replacement preserves the old node's position or shadows intervening conversation; neither represents when the new reading became visible. -- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing. -- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step. -- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings. -- **Mutate assembled requests or register independent prompt variables** — rejected because request-local insertion bypasses the durable surface and separate providers can sample different instants. One attributed context message records the timestamp and elapsed baseline atomically. -- **Copy request zones into a durable authority and absorb post-claim steering into the current step** — rejected because the immutable Session header and entered user-rpc sources already own those facts, while no current production assembly boundary requires inbox reentry. Copying them would add validation and AgentLoop lifecycle solely for a second representation; post-claim steering already receives fresh context in its ordinary next step. -- **Use the process zone or most recent browser as request state** — rejected because deployment state cannot infer a remote user's zone, while a mutable connection default lets travel or concurrent tabs reinterpret another request. The process or configured zone remains only a display fallback for headerless Sessions. -- **Mount the plugin in default compositions or place it in `core/`** — rejected because disclosure, freshness, and history cost are deployment choices for an optional context leaf. A feature-specific overlay may opt in when it has a current consumer. +- **Replace a dynamic system-prompt value** — rejected because replacement erases prior readings and changes reconstructed historical requests. +- **Persist a Session default zone** — rejected because the browser fact belongs to one prompt; travel and concurrent tabs must not mutate shared meaning or spread zone state through Session, fork, and persistence contracts. +- **Copy the browser zone into a second context authority** — rejected because the original user-rpc source already owns it and the invariant can re-derive policy directly. +- **Let Schedule consume the reading implicitly** — rejected because prose context is not a stable typed default and would couple an absolute-time parser to AgentLoop history. The model instead passes an explicit offset or zone. +- **Use only the process zone** — rejected because deployment locality cannot infer a remote user's zone. It remains a display fallback when request provenance is absent or mixed. +- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable round trip and would not ensure a reading before each step. +- **Mount time-context by default** — rejected because disclosure, freshness, and history cost remain composition policy. + +## Verification + +Unit and real-loop tests pin timestamp formatting, unique/mixed/missing browser derivation, fallback display, both elapsed baselines, interval boundaries, cross-turn and resumed scheduling, backward-clock behavior, steering ownership, cancellation, exact snapshot validation, and request reconstruction. Host/client tests pin browser sampling plus validation and canonicalization at prompt entry. The keyless assembled Schedule Web scenario sends a real browser prompt, observes the same zone in the model request, and verifies that the model supplies it explicitly to `schedule_create`. ## Consequences -- Omission or `0` records every entered request step; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume. -- Timing context remains append-only until compaction shadows older surface nodes; a turn that opens no step records no reading. -- First-step duration measures from the previous durable model-visible event, while later-step duration measures model and tool processing since the preceding step context. -- The Session zone is immutable and each browser zone is message-bound, so travel or concurrent tabs expose disagreement instead of changing shared state. -- A headerless Session renders through the configured or deployment-process fallback but remains reported as `unavailable`; elapsed time still uses durable harness append boundaries rather than client-origin timestamps. +- Browser-zone meaning is request-local and durable without changing Session, fork, JSONL, or SQLite schemas. +- The model receives the requested browser-local assumption on each Schedule Web request step; mixed or missing provenance asks instead of guessing. +- Tools remain explicit: context helps the model choose fields but does not become a hidden package-seam default. +- Timing context remains append-only until compaction; a positive interval reduces history growth but can omit fresh browser guidance on later requests. diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 129920a02d..0eff29b14f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -6,64 +6,69 @@ Status: implemented ## 问题 -仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 +仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会抹去先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 -进程本地刷新缓存会使显示时间依赖于一种既无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。 +进程本地刷新缓存会使显示时间依赖于无法在恢复后保留的状态。来自浏览器的自然语言也需要归属于请求的时区:服务端进程时区无法推断用户所在地,而可变的会话或连接默认值会让旅行或并发标签页重新解释另一条提示词。 ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器;当应生成读数且下游决策为进入时,返回一条额外的 `UserMessage`。该消息携带来源 `{ kind: 'plugin', plugin: 'time-context' }`;被抑制、被拒绝或失败的尝试不会追加任何内容。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。默认组合不启用其披露内容与 token 成本;Schedule Web overlay 会挂载它,使模型能够按附加到当前请求的浏览器时区解释未明确限定时区的日期和时间。 -监听器在 `step/start` 之前采样,并仅在最终决定进入时确定该读数。AgentLoop 会在 `step/start` 之后、请求派生之前记录它。因此,下游拒绝或失败会阻止读数进入持久历史。 +该插件会前置一个 `agent/pre-step` 监听器,并先行委托下游。当下游决策进入步骤且需要生成读数时,插件会把该决策的最终消息与开放轮次中已有的持久用户消息合并,从确切的 user-rpc 来源派生浏览器时区来源信息,并向该决策追加一条读数。决策被拒绝、监听器失败或信号已经中止时,不会记录任何内容。在当前批次之后被认领的 steering(中途引导)仍归属于普通的下一步骤,并在该步骤进入时获得新读数。 -省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。 +每条 Web 提示词都会采样浏览器的 IANA 时区。Host 校验并规范化该值,再将其绑定到确切的持久用户消息来源。开放轮次中唯一一个时区可解析请求;多个时区会产生排序后的 `mixed` 结果;没有时区则为 `unavailable`。解析成功的请求会告诉模型,把未限定时区的日期和时间解释为该时区。来源信息混杂或不可用时,模型会收到要求用户澄清的指令。 -插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `user/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。 +这种与消息绑定的来源信息不会复制到 `SessionHeader`、连接默认值或 Schedule 状态。Time-context 只负责模型指导。接受本地日历字段的工具仍必须自行定义显式边界;因此 Schedule 要求 `time_zone`,而不是导入该插件的读数([决策](../simplification/2026-08-09-explicit-schedule-time-zone.md))。 + +解析后的浏览器时区也用于格式化读数中的时间戳。请求来源信息混杂或不可用时,使用配置的 `timeZone` 回退值;如果省略该配置,则使用插件加载时解析一次的 Node 进程时区,同时仍保留要求澄清的策略。每个回退值都经 `Intl.DateTimeFormat` 校验。 + +每个读数都使用确切的快照来源 `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`。不变式配套模块会校验快照形状,从原始 user-rpc 消息重新派生当前轮次的浏览器来源信息,并校验渲染的时间戳时区与经过时长基线。 + +可选配置 `refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,每个符合条件且已进入的步骤都会注入。设为正数时,插件会扫描原始会话事件,查找最新的插件读数;不存在读数、挂钟时间倒退或事件已达到相应时长时执行注入。事件时间戳在压缩和恢复后仍是判断依据,无需进程本地缓存。Schedule Web overlay 会省略该间隔,使每个请求步骤都获得当前浏览器时区指导。 ### 文本与时长基线 -第一个步骤的注入读数为: +已解析的第一步读数为: ```text -Time sampled while preparing turn , step 1: +Time sampled while preparing turn , step 1: +Browser time zone for this request: . Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: . ``` -基线是前一条用户消息、助手消息、工具结果或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 +混杂和不可用的变体会把第二行替换为要求澄清的指令。基线是最新一条在其之前持久化的用户、助手或工具结果消息。为该步骤拟议的提示词尚未追加,因此新会话可能报告 `unavailable`。 -后续步骤的注入读数为: +后续步骤读数会改变第一行的步骤号,并以下行结束: ```text -Time sampled while preparing turn , step : Elapsed since the preceding step context: . ``` -其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。如果间隔抑制导致同一轮次中没有更早的读数,时长为 `unavailable`。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后,仍可归属于对应的历史准备尝试。 +其基线是开放轮次中的前一个 time-context 事件。缺少基线时报告 `unavailable`;时长采用紧凑的整秒单位,并在挂钟时间倒退时限制为零。 -### 持久性与请求重建 +### 持久性与重建 -每个读数都作为普通表层节点保留,直至压缩将其隐藏;正数间隔调度绝不会移除已有读数。因此,后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。 +已进入的步骤会在 `step/start` 之后、请求派生之前,先追加其返回消息,再追加时间读数。后续准备失败时,读数可能留在历史中,因为它记录的是步骤进入,而不是成功传输。每个读数都作为普通表层节点保留,直至压缩将其遮蔽。正数间隔可以让后续请求复用现有历史,而不添加新读数。 -插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为间隔抑制可以让请求进入步骤而不追加读数,拒绝或失败则两者都不追加。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。 +插件不向系统提示词组装或 `request/header` 贡献任何内容。请求重建会在每个 `step/start` 取得完整的持久表层前缀,因此历史请求可以还原模型看到的确切时间与浏览器策略。 -## 测试 +## 已考虑的替代方案 -单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已中止信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。 +- **替换动态系统提示词值**:不予采纳,因为替换会抹去先前读数,并改变重建后的历史请求。 +- **持久化会话默认时区**:不予采纳,因为浏览器事实只属于一条提示词;旅行与并发标签页不得修改共享含义,也不得把时区状态扩散到会话、fork 与持久化约定中。 +- **把浏览器时区复制到第二个上下文权威**:不予采纳,因为原始 user-rpc 来源已经拥有该值,不变式可以直接重新派生策略。 +- **让 Schedule 隐式消费读数**:不予采纳,因为自然语言上下文不是稳定的类型化默认值,而且这会把绝对时间解析器耦合到 AgentLoop 历史。模型会改为传入显式偏移量或时区。 +- **只使用进程时区**:不予采纳,因为部署所在地无法推断远程用户的时区。请求来源信息缺失或混杂时,它仍可作为显示回退值。 +- **只通过工具提供时间**:不予采纳,因为普通时间推理会产生本可避免的往返,也无法确保每个步骤之前都有读数。 +- **默认挂载 time-context**:不予采纳,因为披露内容、新鲜度与历史成本仍属于组合策略。 -## 考虑过的替代方案 +## 验证 -- **保留动态系统提示词区段和进程本地刷新缓存**——不予采纳,因为替换会抹去先前读数,缓存状态无法回放,而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。 -- **替换前一条上下文表层节点**——不予采纳,因为替换会保留旧节点的位置或隐藏中间的会话内容;两者都不能表达新读数何时开始可见。 -- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。 -- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。 -- **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。 -- **修改已组装的请求或注册独立提示词变量**——不予采纳,因为请求内插入会绕过持久表层,不同提供方也可能在不同时间采样。一条带来源归属的上下文消息会原子地记录时间戳和时长基线。 -- **默认使用 UTC 或增加时区检测依赖**——不予采纳,因为显式挂载的插件默认遵循其进程环境,除非操作方选择 IANA 时区,而任何服务端库都无法推断远程用户的时区。 -- **在已交付组合中挂载插件,或把它放进 `core/`**——不予采纳,因为披露内容、时区、新鲜度和历史成本是可选上下文叶节点的部署选择,不是产品主干策略。 +单元测试和真实 agent loop(智能体循环)测试固定时间戳格式化、唯一/混杂/缺失浏览器时区的派生、回退显示、两种经过时长基线、间隔边界、跨轮次与恢复后的调度、挂钟倒退行为、steering 归属、取消、精确快照校验和请求重建。Host/client 测试固定浏览器采样,以及提示词进入时的校验与规范化。无密钥的组装 Schedule Web 场景发送一条真实浏览器提示词,在模型请求中观察到同一时区,并验证模型把该时区显式传给 `schedule_create`。 ## 后果 -- 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。 -- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。 -- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。 -- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。若要支持客户端来源的时间,需要另行建立持久输入约定。 +- 浏览器时区含义归属于请求并可持久重建,无需更改会话、fork、JSONL 或 SQLite schema。 +- 模型在每个 Schedule Web 请求步骤中都会收到所请求的浏览器本地假设;来源信息混杂或缺失时会询问,而不是猜测。 +- 工具仍保持显式边界:上下文帮助模型选择字段,但不会成为包 seam 上隐藏的默认值。 +- 时间上下文仅追加并保留到压缩为止;正数间隔会减少历史增长,但也可能使后续请求缺少新的浏览器时区指导。 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index aeb104ce8c..f437b44f75 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md -2026-08-05-durable-web-schedule.md: 063f27d5bae6194b172d6998f338c11a4065bbd0 -2026-08-05-durable-web-schedule.zh.md: b8128d8d8e26401106a66a17a63cf8bc947b914f +2026-08-05-durable-web-schedule.md: f107d5389ef7650b0af41b9e7dc9bd35ec0654fe +2026-08-05-durable-web-schedule.zh.md: e170b0bf8b96526ef0f458e5583da42fa023137e diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index 063f27d5ba..f107d5389e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -1,4 +1,4 @@ -# Agent Note: Durable Session-local Web reminders +# Agent Note: Durable Session-local reminders Status: implemented @@ -6,116 +6,70 @@ English | [中文](2026-08-05-durable-web-schedule.zh.md) ## Problem -A reminder created inside a conversation needs to survive a process restart and remain attributable to that exact Session. A process-local timer or model inbox item cannot provide that durability, while a global scheduler or private database would introduce a second identity, persistence, and lifecycle system. The user also needs a visible receipt even when the best-effort model turn later fails, without seeing a reminder whose dispatch never reached storage. +A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. A process-local timer or inbox item cannot provide that durability, while a global scheduler or private database introduces a second identity, persistence, and lifecycle system. -Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, and browser history races make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and merge a presentation sidecar that can arrive after the underlying event. +Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, absolute calendar input, and teardown make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and avoid spreading Schedule-specific presentation or time-zone state across unrelated components. ## Decision -The [`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay explicitly loads `@deepseek-ai/dsh-time-context`, `@deepseek-ai/dsh-tool-schedule`, and the separate `@deepseek-ai/dsh-client-ui-schedule` renderer. The default Web tree remains unchanged. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate it. +The [`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-tool-schedule`; the default Web tree remains unchanged. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate it. -The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while it is live, does no external notification while cold, and processes an overdue reminder after that Session becomes live again. +The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while live, does no external notification while cold, and processes an overdue reminder after it becomes live again. Due work waits until the Agent is fully idle, then enters the ordinary next-turn queue through `followup()`; it never steers the current turn and has no independent Web receipt ([conversational delivery](../simplification/2026-08-09-conversational-schedule-delivery.md)). | Scenario | Durable fact | Live behavior | User-visible result | | --- | --- | --- | --- | -| Create and manage | `schedule/change` create/delete events in the original Session | Agent-scoped tools checkpoint before reading and after mutations | Stable id, UTC target, `scheduled`/`overdue`, and `session-local` disclosure | -| Due while busy | Active create remains in the fold | Owner waits for `whenIdle()`, claims idle maintenance, queues one followup, then appends dispatch | One replayable reminder receipt; model failure does not retract it | -| Process stopped or Session cold | Active create remains in persistence | No timer or background scan exists; resume rebuilds the owner | Future target waits again; overdue target is attempted once | -| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent receipt may appear in history, but no parent reminder becomes active child work | +| Create and manage | `schedule/change` create/delete in the original Session | Agent-scoped tools checkpoint before reads and after mutations | Stable id, UTC target, state, and `session-local` disclosure | +| Due while busy | Active create remains in the fold | Owner waits for idle maintenance, queues one follow-up, then appends dispatch | A later ordinary conversation turn | +| Process stopped or Session cold | Active create remains persisted | No timer or background scan; resume rebuilds the owner | Future target waits; overdue target is attempted | +| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent work does not become active in the child | -### Session log authority and tools +### Session-log authority and tools -The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed user prompt, the rule, and its UTC target. Delete and dispatch are terminal transitions. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`. +The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`. -The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a structured `{ date, time, time_zone? }` local value; its record is `{ id, kind: 'at', prompt, scheduledAt }`. Both dispatch shapes store only the id because the active record already fixes the occurrence. `every_seconds` and `cron` remain rejected rather than hidden in unused fields. Tool values derive `scheduled` or `overdue` and always include `deliveryMode: 'session-local'`. +The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either strict RFC 3339 with `Z` or a numeric offset, or structured `{ date, time, time_zone }` with an explicit zone; its record is `{ id, kind: 'at', prompt, scheduledAt }`. Dispatch stores only the id because the active record fixes the occurrence. Tool values derive `scheduled` or `overdue` and include `deliveryMode: 'session-local'`. -An Agent-scoped FIFO serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Every tool operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create may reject input-shape failures before entering the FIFO; after a successful preflight it allocates an id, appends create, and waits for a second barrier. Delete validates its id before the FIFO, then preflights before deciding whether the id is active and waits for a second barrier only when it appends. List and unknown or finished delete never answer from an unconfirmed live suffix or observe a dispatch before its own barrier. A failed barrier returns `persistence_uncertain` rather than guessing whether an eager write committed. +An Agent-scoped FIFO serializes management transactions and the live owner's due transaction from preflight through post-append barriers. Every tool read first awaits `ctx.sessions.flush(session)`. Create rejects input-shape failures before the FIFO when possible, preflights, allocates an id, appends, and checkpoints again. Delete validates its id before the FIFO, preflights before deciding whether it is active, and checkpoints again only after append. List and not-found delete never answer from an unconfirmed live suffix. Failed barriers return `persistence_uncertain` rather than guessing whether an eager write committed. -Every successful management preflight also asks the live owner to recompute. This closes the recovery path where create appended successfully but its post-append barrier rejected: a later list can confirm the coordinator's retained batch, return the active record, and arm its timer without a Schedule-specific retry loop. +Every successful management preflight asks the live owner to recompute. A later list can therefore confirm a retained create after a previous post-append rejection and arm it without a private persistence-retry timer. -### Session and request time-zone ownership +### Explicit absolute-time boundary -The official Web create path requires the browser's IANA zone, validates and canonicalizes it at the Host boundary, and stores it once as immutable `SessionHeader.timeZone`. Resume preserves that value, fork copies it, and another create for the same id and cwd conflicts when its canonical zone differs. Session core keeps the field optional so pre-zone Sessions remain readable but explicitly `unavailable`; a legacy header is never backfilled from a later browser request. JSONL preserves the optional header, while SQLite schema v14 adds nullable `time_zone` and upgrades an owned v13 database atomically without guessing values for existing rows. +Natural-language interpretation and Schedule parsing are deliberately separate ([time-zone simplification](../simplification/2026-08-09-explicit-schedule-time-zone.md)). Each browser prompt carries its Host-validated IANA zone only on that durable user message. Time-context tells the model to assume that zone for otherwise-unqualified dates and times. Schedule neither imports that plugin nor stores a Session zone: the model must turn its interpretation into an offset-bearing RFC 3339 value or a local object with explicit `time_zone`. -That exact v13-to-v14 transaction is a narrow planned exception to the pre-release default of rejecting old storage formats: valid headerless Session databases can exist before time-zone metadata is introduced. It accepts only the owned v13 layout, rejects older, newer, or spoofed schemas without mutation, and does not establish a general migration framework. - -Every Web prompt samples its own `clientTimeZone`, which the Host validates before Agent entry and binds to that immutable `user-rpc` message source. This is request provenance, not a mutable property of the connection or Session, so concurrent tabs cannot overwrite one another and queue, steering, edit, retry, and persisted history retain the originating zone. - -Time-context delegates through `agent/pre-step`, derives the final non-empty entered batch's zones from the immutable Session header and message-bound browser sources, and appends one model-visible reading to that batch. Its source remains the simple plugin marker; it does not copy those facts into another durable authority. Steering inserted after AgentLoop claims the current batch keeps ordinary next-step ownership and receives fresh context when that step enters. Rejection, an empty decision, cancellation, or failure before `step/start` records no reading, and this feature adds no inbox or AgentLoop lifecycle state. - -Schedule requires a time-context marker in the current open turn, then derives request zones directly from that turn's original `user-rpc` sources. An implicit local `at` is accepted only when that derivation has one client zone equal to the Session zone. A headerless Session, missing or mixed client provenance, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones. An explicit `time_zone` bypasses that ambiguity check but still passes the same IANA validation. - -### Absolute-time normalization - -Schedule, rather than the model or process locale, owns deterministic calendar normalization. Explicit-offset input must match the narrow supported profile and identify a strictly future four-digit-year instant. Structured local input validates the calendar and selected zone, rejects a daylight-saving gap, and chooses the first, earlier instant in an overlap. A successful create stores only UTC `scheduledAt`; the original offset, local fields, and interpreting zone are not a second durable representation. Natural-language interpretation remains the model's job, and time-context appears before the tool call rather than relying on a result echo. - -### Persistence checkpoint and initialization recovery - -`SessionStore.flush()` awaits every scoped listener and treats literal `true` as an explicit durability acknowledgement. An acknowledged call publishes a contained `session/flushed(session, throughSeq)` observation whose exclusive boundary was captured at call entry; append notification itself is not durability evidence. Observe-only listeners return void, an empty or observe-only checkpoint returns `false`, and any listener rejection prevents the success observation after all listeners settle. - -The persistence coordinator supplies that acknowledgement only after its write path is quiescent. Its live controller retains the initial `seedEnd` scalar rather than a seed copy. If the first initialization rejects, a later flush rebuilds that immutable prefix from the append-only Session, reads the backend's actual cursor, and appends only a missing suffix. This covers failures before storage changed and failures reported after a commit, so one transient error neither permanently poisons the Session nor duplicates its prefix. +Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A successful create stores only canonical UTC `scheduledAt`, not the original offset, local fields, or zone. ### Live delivery lifecycle -The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the claim; the record stays active and one `whenIdle()` wait triggers a later retry. A rejected persistence preflight or contained framing/synchronous-enqueue failure also leaves the record active, but no private retry timer runs; later Agent activity reaching idle or a successful Schedule management preflight asks the owner to try again. +The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. If a turn or maintenance task owns the Agent, `runMaintenance()` rejects the claim; the record stays active and one `whenIdle()` wait triggers another attempt. A rejected preflight or contained framing/enqueue failure also leaves it active without starting a private retry timer. -The accepted path first clears pending persistence and claims the true idle phase through `runMaintenance()`. Inside that task it refolds the exact Session suffix so a direct management mutation that won the claim race cannot be followed by a stale dispatch, samples the decision clock once, constructs the complete fixed reminder frame with JSON-escaped id and prompt, synchronously queues one `followup()`, and appends the id-only dispatch. Waking input remains parked until maintenance settles, so the driver cannot claim the message before dispatch enters the log; only after the task releases the phase does the owner wait for the dispatch barrier. A framing or synchronous enqueue failure is contained and appends no dispatch. An append failure faults that owner because the message may already be queued. A later prompt-admission, request-checkpoint, or model failure cannot retract a dispatch. +The accepted path clears pending persistence and claims the true idle phase. It refolds the exact Session suffix, samples the decision clock, constructs fixed reminder framing with JSON-escaped id and prompt, synchronously queues one `followup()`, and appends id-only dispatch before releasing maintenance. Waking input remains parked until release, so the message cannot be claimed before dispatch enters the log; afterward the owner checkpoints dispatch. -Agent or plugin disposal cancels timers, stops new work, unwinds the three tool registrations, and waits for in-flight preflights or idle waits. It never deletes durable records during teardown. The narrow crash interval after synchronous followup admission and before durable dispatch may repeat the reminder after recovery; the design prefers a visible duplicate over silent loss and makes no model-success, user-read, external-effect, or exactly-once promise. - -### Commit-aware Web receipt - -The Schedule package owns `scheduleReminderPresentation()`, which derives `{ scheduleId, prompt, occurrenceAt }` from create plus dispatch; the client renderer adds the fixed `session-local` label. The current fork's `seedLength` is a hard boundary for child-owned dispatches. An inherited dispatch instead pairs with its nearest preceding same-id create because `session/end-seed` also marks replay or resume construction, not only fork ownership. This keeps resumed ancestor receipts renderable, preserves nested-generation id reuse, and never changes live ownership. - -The Host continues to send every raw event on append. It keeps one monotonic watermark per exact live `Session` in a `WeakMap`; only `session/flushed` advancement makes it redeliver newly covered dispatch events with the generic `{ for: 'event', view }` sidecar. The durable `schedule/change` type selects the client renderer. Taking the maximum contains reversed concurrent flush completion, and exact object identity prevents a reused Session id from inheriting another lifecycle's cursor. - -Attached history independently inspects persistence and adds views only to a stored event prefix whose header identity and every event match the live Session. Persistence canonically writes absent top-level `delegationDepth` as zero, so those two forms are identity-equivalent; cwd, lineage, origin, timestamps, version, id, and every event still match exactly. Missing, failed, divergent, or longer inspection withholds the view while returning raw history. Detached history is already a persisted prefix. A parent dispatch copied into a fork seed therefore appears in child history only after child storage proves that prefix. - -The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar immediately without appending another event. Tail loading and true gap repair retain uncovered events in the existing `liveBuffer`; an accepted repair snapshot starts another pull when it advanced the tail but left a later buffered gap, while an identity conflict triggers a full resync. Ordinary older-page pagination keeps receiving live tail events in the current arrays, while a sidecar below the current window stays with the in-flight page and attaches only when that page returns the identical event. Reconnect generations prevent stale page or repair results and `finally` blocks from touching the rebuilt window. `TranscriptAdapter` creates a generic `PresentedEventNode` keyed by the durable event type. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual `schedule/change` reminder row. - -```text -schedule_create → Session create event → persistence - ↓ live owner -due → admission → followup → dispatch → flush(true) → session/flushed - ↓ - Host late event sidecar - ↓ - client same-seq upgrade → event-keyed UI receipt -``` +Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. Agent or plugin disposal cancels timers, stops new work, unwinds tool registrations, and awaits in-flight work without deleting durable records. A crash after follow-up admission but before durable dispatch can repeat the reminder after recovery; the design makes no exactly-once promise. ## Alternatives considered -**Use `ctx.tasks`.** Tasks own process-local work, terminal outcomes, collection, and notifications rather than Session-log state and replayable conversation receipts. Reusing them would make the wrong lifecycle authoritative. +**Use `ctx.tasks`.** Tasks own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups. -**Store reminders in a private SQLite table or global scheduler.** This could run cold Sessions, but requires a second Session identity map, startup scan, ownership lease, crash protocol, and notification policy. The accepted scope deliberately runs only while the original Session is live. +**Store reminders in a private database or global scheduler.** This could run cold Sessions but requires a second identity map, startup scan, ownership lease, crash protocol, and notification policy. -**Claim dispatch before `followup()` or add exactly-once fencing.** A claim-first record can silently lose the user-visible reminder when enqueue fails. Cross-process exactly-once requires a lease, outbox, acknowledgement, and downstream idempotency boundary that Session-local best-effort model work does not provide. +**Persist a Session time zone and infer local `at`.** This spreads one interpretive default through Session core, Host create/fork, persistence formats, clients, and mismatch recovery. Request-local model guidance plus an explicit tool boundary deletes that coupling. -**Treat the model message as the receipt.** The queued inbox item is process-local and may fail before a durable user message exists. A dispatch-derived Web receipt remains visible and replayable independently of model success. +**Keep an independent durable Web receipt.** Dispatch is an internal queue fact, not the user's reminder. Rendering the ordinary assistant answer avoids a second delivery meaning and removes Schedule code from Host and client layers. -**Attach the reminder view on append.** `session/event` precedes the durability result, so this would display a ghost receipt after a rejected flush. The success watermark makes presentation follow the commit point. +**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this Session-local scope. -**Add a Schedule-specific wire frame, client cache, or management page.** The generic event sidecar, existing Session window buffer, keyed slot, and model-facing tools already carry the required result. A parallel transport or state store would duplicate identity and replay logic. - -**Adopt existing roots or register global tools.** Late adoption makes plugin load order change which unseen timers begin running and exposes tools outside the supported root-Agent composition. Future-root, Agent-scoped installation gives one clear lifecycle. - -**Use the process zone or the most recently connected browser as the default.** The process zone is deployment state, while a connection-level value lets one tab or a later trip silently reinterpret another request. An immutable Session default plus message-bound client provenance makes disagreement visible without creating shared mutable zone state. - -**Parse arbitrary natural-language dates inside Schedule or persist the local input.** A second language parser would compete with the model, and retaining local text or zone beside the resolved instant would create two durable interpretations of one one-shot target. The model emits a narrow structure after seeing time-context; Schedule validates it and stores one UTC fact. - -The design does not recognize or migrate any unmerged Schedule implementation or private storage format. No fixed Session id, claim-before-send record, startup miss, or private database is a compatibility input. +**Adopt existing roots or register global tools.** Late adoption makes plugin load order activate unseen timers and exposes tools outside the supported root composition. ## Verification -Package tests pin strict decoding, transitions, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, gap rejection, overlap-first selection, mismatch confirmation, time bounds, bounded waits, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at 100% per-file coverage. Persistence tests cover new, fork, and resumed initialization failures against the actual durable cursor, optional header round-trips, a real SQLite v13-to-v14 migration, and a production JSONL restart. The assembled Loader/Web restart lane proves pending recovery, fork isolation, one durable dispatch, cold-history rendering without Agent activation, and no redelivery after another restart. Host/client tests cover zone identity across live, stored, and concurrent-create paths; per-operation prompt provenance; commit gating; reversed watermarks; semantic header identity; per-event prefix matching; same-seq upgrades; every window merge exit; and reconnect generations. - -Time-context tests cover final pre-step messages, current-turn unique/mixed/missing zone derivation, post-claim steering entering the next step, cancellation, empty suppression, retry, exact snapshot-source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources, reuse a same-turn marker across an empty continuation, and fail closed without an open-turn marker. The opt-in Loader composition boots the source and built packages. Keyless real-browser scenarios execute `schedule_create` through the complete tool pipeline for the existing short `after` case and one absolute-time case, observe the identity-matched persisted prefix, and render the durable reminder card from attached history. The deliberately absent model adapter closes the turn with an error after dispatch, proving that model failure does not remove the receipt. +Package tests pin strict replay, transitions, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at per-file 100% coverage. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Host/client tests pin browser-zone sampling and prompt-bound validation. The keyless assembled Web scenario drives a real browser prompt through time-context, a model `schedule_create` call with explicit `time_zone`, durable dispatch, and an ordinary assistant follow-up with no receipt UI. ## Consequences -- Reminder state survives process restart and replays through ordinary Session persistence without a new database or public service. -- A cold Session does no work and sends no external notification; reopening it may deliver an overdue reminder, and every tool/card says `session-local`. -- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. Long waits and plugin unload do not create a second durable state machine. -- A Session's default zone is immutable and may remain unavailable for older history. Travel or concurrent tabs can therefore require an explicit zone instead of silently changing the meaning of “tomorrow at 09:00.” -- The generic commit-aware event-view path is reusable by other durable events, but it adds event-identity checks and generation-aware merge behavior to the client Session window. -- The strict one-shot protocol covers delayed and absolute targets. Recurring rule families still require explicit transition, catch-up, and model-budget semantics rather than dormant fields. +- Reminder state survives restart through ordinary Session persistence without a new database or public service. +- Cold Sessions do no work and send no external notification; reopening one may deliver overdue work. +- Absolute input is deterministic without persistent Session-zone state or a dependency from Schedule to time-context. +- Users see normal conversation output; dispatch never overstates model success or acknowledgement. +- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. +- Recurrence requires explicit transition, catch-up, and model-budget semantics rather than dormant fields; cron remains outside this product boundary. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index b8128d8d8e..e170b0bf8b 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 持久、仅限 Session 内的 Web 提醒 +# Agent Note: 持久、仅限 Session 内的提醒 Status: implemented @@ -6,116 +6,70 @@ Status: implemented ## 问题 -在对话中创建的提醒需要跨进程重启存活,并始终归属于确切的原 Session。进程内 timer 或模型 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。即使后续 best-effort 模型轮次失败,用户仍需要看到回执;但 dispatch 尚未到达存储的提醒绝不能提前显示。 +在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。进程本地 timer 或 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。 -繁忙的 Agent、长等待、墙钟变化、cold Session、fork、持久化失败和浏览器 history 竞态,使简单 timeout 无法满足要求。设计必须区分持久 record 与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并合并可能晚于原始 event 到达的 presentation sidecar。 +繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session、fork、持久化失败、绝对日历输入和资源释放,使简单 timeout 无法满足要求。设计必须区分持久记录与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并避免把 Schedule 专属的呈现或时区状态扩散到无关组件。 ## 决策 -[`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay 显式加载 `@deepseek-ai/dsh-time-context`、`@deepseek-ai/dsh-tool-schedule` 与独立 renderer `@deepseek-ai/dsh-client-ui-schedule`。默认 Web 配置树保持不变。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他宿主都不会激活它。 +[`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay 显式加载 `@deepseek-ai/dsh-time-context` 与 `@deepseek-ai/dsh-tool-schedule`;默认 Web 配置树保持不变。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他 host 都不会激活它。 -用户可见边界固定为 `session-local`:原 Session 只有在 live 时才会准点运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。 +用户可见边界是 `session-local`:原 Session 只有在 live 时才会准时运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。到期工作会等待 Agent 完全 idle,再通过 `followup()` 进入普通的下一轮队列;它绝不会中途引导当前轮次,也没有独立 Web 回执([对话式交付](../simplification/2026-08-09-conversational-schedule-delivery.md))。 | 场景 | 持久事实 | live 行为 | 用户可见结果 | | --- | --- | --- | --- | -| 创建与管理 | 原 Session 中的 `schedule/change` create/delete event | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、`scheduled`/`overdue` 与 `session-local` 说明 | -| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 `whenIdle()`、认领 idle maintenance、排入一次 followup,再追加 dispatch | 一条可回放提醒回执;模型失败不会撤回它 | -| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标尝试一次 | -| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | history 可显示父回执,但父提醒不会成为 child 活动工作 | +| 创建与管理 | 原 Session 中的 `schedule/change` create/delete | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、状态与 `session-local` 说明 | +| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 idle maintenance,排入一个 follow-up,再追加 dispatch | 后续一个普通对话轮次 | +| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标会被尝试 | +| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | 父工作不会在 child 中变为活动状态 | ### Session 日志权威与工具 -版本 1 `schedule/change` stream 是唯一持久 Schedule 权威。create record 拥有 Session 内不复用的品牌 id、trim 后的用户 prompt、规则与 UTC 目标。delete 和 dispatch 是终结 transition。严格 decoder 与 pure fold 会拒绝未知版本、额外字段、重复 id,以及针对非活动 record 的 transition。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。 +版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。 -当前规则 union 接受非空提示词与恰好一个 selector。`after_seconds` 是正 safe-integer delay,其 record 为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数字 offset 的严格 RFC 3339 date-time,也可以是结构化的 `{ date, time, time_zone? }` local value;其 record 为 `{ id, kind: 'at', prompt, scheduledAt }`。两种 dispatch shape 都只保存 id,因为活动 record 已经唯一确定 occurrence。`every_seconds` 与 `cron` 仍会被拒绝,不会作为未使用字段隐藏在协议中。工具 value 派生 `scheduled` 或 `overdue`,并始终包含 `deliveryMode: 'session-local'`。 +当前规则 union 接受非空提示词和恰好一个 selector。`after_seconds` 是正的安全整数 delay,其记录为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的值,也可以是带显式时区的结构化 `{ date, time, time_zone }`;其记录为 `{ id, kind: 'at', prompt, scheduledAt }`。dispatch 只保存 id,因为活动记录已经确定 occurrence。工具值派生 `scheduled` 或 `overdue`,并包含 `deliveryMode: 'session-local'`。 -一个 Agent-scoped FIFO 会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。每项从 fold 读取或作出判断的工具操作都会先等待 `ctx.sessions.flush(session)`。create 可以在进入 FIFO 前拒绝只依赖输入 shape 的失败;preflight 成功后才分配 id、追加 create,并等待第二个 barrier。delete 在进入 FIFO 前验证其 id,随后在判断 id 是否活动前先 preflight,只有实际追加时才等待第二个 barrier。list 与未知或已终结 delete 绝不会从未确认的 live 后缀作答,也不会在自身的 barrier 前观察到 dispatch。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。 +一个 Agent-scoped FIFO 会将管理事务与 live owner 的到期事务从 preflight 到 post-append barrier 全程串行化。每项工具读取都会先等待 `ctx.sessions.flush(session)`。create 会尽可能在进入 FIFO 前拒绝输入形状错误,随后执行 preflight、分配 id、追加记录并再次 checkpoint。delete 会在进入 FIFO 前验证 id,在判断其是否活动前执行 preflight,并且只在追加后再次 checkpoint。list 与 not-found delete 绝不会根据未经确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。 -每次成功的管理 preflight 也会要求 live owner 重新计算。这闭合了 create 已成功追加、但 post-append barrier 拒绝时的恢复路径:后续 list 可以确认 coordinator 保留的 batch、返回活动 record,并在没有 Schedule 私有重试循环的情况下 arm timer。 +每次成功的管理 preflight 也会要求 live owner 重新计算。因此,如果先前的 post-append 被拒绝,后续 list 可以确认保留的 create 并将其 arm,而无需私有的 persistence 重试 timer。 -### Session 与请求时区归属 +### 显式绝对时间边界 -官方 Web create 路径要求浏览器提供 IANA 时区,在 Host 边界校验并规范化后,将其一次性存为不可变的 `SessionHeader.timeZone`。resume 保留该值,fork 复制该值;若针对相同 id 与 cwd 的另一次 create 得到的规范化时区不同,则发生冲突。Session core 保持该字段可选,使时区支持前的 Session 仍可读取,但其时区明确为 `unavailable`;绝不会用后续浏览器请求回填 legacy header。JSONL 保留该可选 header;SQLite schema v14 增加 nullable `time_zone`,并以原子方式升级自有 v13 数据库,不为既有行猜测值。 +自然语言解释与 Schedule 解析被有意分开([时区简化](../simplification/2026-08-09-explicit-schedule-time-zone.md))。每条浏览器提示词只在其对应的持久 user message 上携带由 Host 校验过的 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该时区。Schedule 既不导入该插件,也不存储 Session 时区:模型必须把其解释结果转换为带偏移量的 RFC 3339 值,或带显式 `time_zone` 的本地对象。 -这笔精确的 v13 到 v14 事务,是对“预发布阶段默认拒绝旧存储格式”立场的一项窄幅、已规划例外:在引入时区 metadata 前,可能已经存在有效的无时区 Session 数据库。它只接受自有 v13 布局;更旧、更新或伪造的 schema 都会在不修改数据的前提下被拒绝,而且不会建立通用迁移框架。 - -每条 Web 提示词都会单独采样自己的 `clientTimeZone`;Host 在进入 Agent 前校验该值,并把它绑定到不可变的 `user-rpc` 消息来源。它是请求 provenance,而不是连接或 Session 的可变属性,因此并发 tab 无法相互覆盖,排队、steering(中途引导)、编辑、重试和持久化 history 都会保留来源时区。 - -Time-context 会委托 `agent/pre-step`,从不可变 Session header 和与消息绑定的浏览器来源为最终进入的非空批次派生时区,再向该批次追加一条模型可见读数。其来源仍是简单插件标记,不会把这些事实复制成另一份持久权威。AgentLoop 领取当前批次后才插入的 steering(中途引导)保留常规 next-step 归属,并在该步骤进入时获得新上下文。`step/start` 之前出现 reject、空决策、取消或失败时,不会记录读数;本功能也不增加 inbox 或 AgentLoop 生命周期状态。 - -Schedule 要求当前 open turn 中存在 time-context 标记,然后直接从该 turn 的原始 `user-rpc` 来源派生请求时区。只有派生结果包含一个与 Session 时区相等的 client 时区,才会接受隐式 local `at`。无 header 的 Session、client provenance 缺失或 mixed,或 client/Session 不匹配,都会返回 `timezone_confirmation_required` 及已知时区。显式 `time_zone` 可绕过这项歧义检查,但仍要通过相同的 IANA 校验。 - -### 绝对时间规范化 - -确定性的日历规范化由 Schedule 负责,而不是模型或进程 locale。显式 offset 输入必须匹配受支持的窄 profile,并标识一个严格位于未来、年份为四位数的时点。结构化 local 输入会校验日历和选定时区,拒绝夏令时空档,并选择重叠时段中首次出现的较早时点。成功的 create 只存储 UTC `scheduledAt`;原 offset、local 字段和用于解释的时区不会形成第二份持久表示。自然语言解释仍由模型完成,time-context 出现在工具调用之前,而不依赖结果回显。 - -### Persistence checkpoint 与初始化恢复 - -`SessionStore.flush()` 会等待所有 scoped listener,并把字面量 `true` 视为显式 durability acknowledgement。获得确认的调用会发布受包含的 `session/flushed(session, throughSeq)` observation;其中排他边界在调用入口捕获,append 通知本身不是 durability 证据。仅观察 listener 返回 void;空或只有观察者的 checkpoint 返回 `false`;任一 listener 拒绝都会在全部结算后阻止成功 observation。 - -persistence coordinator 只有在写路径完全停稳后才给出该确认。live controller 只保留初始 `seedEnd` 标量,不复制 seed。首次初始化拒绝后,后续 flush 会从仅追加 Session 重建该不可变前缀、读取后端实际 cursor,并只追加缺失 suffix。无论失败发生在存储变更前,还是提交后才返回拒绝,一次暂时性错误都不会永久毒化 Session 或重复写入其前缀。 +Schedule 会校验精确的日历形状、偏移量、时区名称,以及一个严格位于未来、年份为四位数的时点。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,不会存储原始偏移量、本地字段或时区。 ### Live 交付生命周期 -Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;record 保持活动,并由一个 `whenIdle()` wait 触发稍后的重试。被拒绝的 persistence preflight 或被收容的 framing/同步入队失败同样会让 record 保持活动,但不会运行私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 会要求 owner 再次尝试。 +Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都会重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。如果 Agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;记录保持活动,并由一次 `whenIdle()` wait 触发另一次尝试。被拒绝的 preflight 或被收容的 framing/入队失败同样会使记录保持活动,但不会启动私有重试 timer。 -获得准入的路径会先清空 pending persistence,并通过 `runMaintenance()` 认领真正的 idle phase。该任务会重新折叠确切的 Session 后缀,从而确保在认领竞态中胜出的直接管理变更之后不会跟随陈旧 dispatch;随后只采样一次 decision clock,使用 JSON-escaped id 与 prompt 构造完整固定 reminder frame,同步排入一次 `followup()`,再追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到 maintenance 结束,因此 driver 无法在 dispatch 进入 log 前认领消息;只有该任务释放 phase 后,owner 才会等待 dispatch barrier。framing 或同步入队失败会被收容,且不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。 +获得准入的路径会刷新所有 pending persistence 并认领真正的 idle phase。它会重新折叠确切的 Session 后缀、采样 decision clock、用经过 JSON 转义的 id 和提示词构造固定提醒 framing、同步排入一个 `followup()`,并在释放 maintenance 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到 maintenance 释放,因此在 dispatch 进入日志前,消息不会被认领;随后 owner 会为 dispatch 执行 checkpoint。 -Agent 或插件 dispose 会取消 timer、停止新工作、撤销三个工具注册,并等待进行中的 preflight 或 idle wait。teardown 绝不会删除持久 record。同步 followup 获得准入后、durable dispatch 前的狭窄崩溃窗口可能在恢复后重复提醒;本设计选择可见重复而非静默丢失,不承诺模型成功、用户阅读、外部副作用或 exactly-once。 - -### Commit-aware Web 回执 - -Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt }`。client renderer 会添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界。继承的 dispatch 则会与它之前最近的同 id create 配对,因为 `session/end-seed` 也会标记回放或恢复构造,而不仅标记 fork 所有权。这使恢复后的祖先回执仍可渲染,保留嵌套 generation 的 id 复用,并且绝不会改变 live ownership。 - -Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact live `Session` 保存一个单调 watermark;只有 `session/flushed` 前进时,才会用通用 `{ for: 'event', view }` sidecar 重投新覆盖的 dispatch event。持久 `schedule/change` 类型用于选择 client renderer。取最大值可以收容反序完成的并发 flush,按对象身份键控则阻止复用的 Session id 继承另一个生命周期的 cursor。 - -已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 view,raw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。 - -浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后立即升级 sidecar,不再追加 event。尾部加载与真正的 gap repair 会将尚未覆盖的事件保留在既有 `liveBuffer` 中;已接受的 repair 快照在推进 tail 但仍留下后续已缓冲的 gap 时会启动另一次 pull,身份冲突则会触发全量重新同步。普通旧页分页会让当前数组继续接收 live tail 事件,当前 window 以下的 sidecar 则由 in-flight page 自身暂存,只有该页返回身份完全相同的事件时才附着。重连 generation 会阻止陈旧的 page 或 repair 结果以及 `finally` 块触碰重建后的 window。`TranscriptAdapter` 创建按持久事件类型键控的通用 `PresentedEventNode`。`ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback;`ui-schedule` 则拥有双语 `schedule/change` 提醒行。 - -```text -schedule_create → Session create event → persistence - ↓ live owner -due → admission → followup → dispatch → flush(true) → session/flushed - ↓ - Host late event sidecar - ↓ - client same-seq upgrade → event-keyed UI receipt -``` +dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。Agent 或插件 dispose 会取消 timer、停止新工作、撤销工具注册,并等待进行中的工作,且不会删除持久记录。follow-up 获得准入后、持久 dispatch 前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。 ## 已考虑的替代方案 -**使用 `ctx.tasks`。** Task 拥有进程内工作、终态结果、收集与通知语义,而不是 Session 日志状态和可回放会话回执。复用它会让错误的生命周期成为权威。 +**使用 `ctx.tasks`。** Task 拥有进程本地工作、结果和通知,而不是 Session 日志状态和对话 follow-up。 -**把提醒存入私有 SQLite 表或全局 scheduler。** 这样可以运行 cold Session,却必须增加第二套 Session 身份映射、startup 扫描、ownership lease、崩溃协议与通知政策。当前范围有意只在原 Session live 时运行。 +**把提醒存入私有数据库或全局 scheduler。** 这样可以运行 cold Session,却需要第二套身份映射、启动扫描、ownership lease、崩溃协议和通知策略。 -**在 `followup()` 前 claim dispatch,或增加 exactly-once fencing。** claim-first record 会在入队失败时静默丢失用户可见提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,而 Session-local best-effort 模型工作不具备这些边界。 +**持久化 Session 时区并推断本地 `at`。** 这会让一个解释默认值扩散到 Session core、Host create/fork、持久化格式、client 和不匹配恢复中。请求本地的模型指导与显式工具边界消除了这种耦合。 -**把模型消息当作回执。** 已排队 inbox 项是进程内状态,可能在产生持久 user message 前失败。从 dispatch 派生的 Web 回执不依赖模型成功,仍然可见、可回放。 +**保留独立的持久 Web 回执。** dispatch 是内部队列事实,而不是用户的提醒。渲染普通 assistant 回答既避免了第二种交付含义,也从 Host 与 client 层移除了 Schedule 代码。 -**在 append 时附加提醒 view。** `session/event` 早于 durability 结果;这样会在 flush 拒绝后显示幽灵回执。成功 watermark 让 presentation 服从提交点。 +**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了此 Session-local 范围。 -**增加 Schedule 专属 wire frame、client cache 或管理页面。** 通用 event sidecar、既有 Session window buffer、键控 slot 与面向模型工具已经能承载所需结果。平行 transport 或状态 store 会重复身份与回放逻辑。 - -**接管既有根或注册全局工具。** 晚接管会让插件加载顺序改变哪些不可见 timer 开始运行,并把工具暴露到支持范围之外。只面向未来根、按 Agent scope 安装,提供了单一明确生命周期。 - -**将进程时区或最近连接的浏览器用作默认值。** 进程时区属于部署状态,而连接级值会让某个 tab 或后续出行悄然重新解释另一个请求。不可变的 Session 默认值加上绑定到消息的 client provenance,能让分歧显现,而不创建共享的可变时区状态。 - -**在 Schedule 内解析任意自然语言日期,或持久化 local 输入。** 另一套语言解析器会与模型竞争,而在已解析时点旁保留 local 文本或时区,会为同一个一次性目标形成两种持久解释。模型看到 time-context 后输出一个窄结构;Schedule 校验它并存储一个 UTC 事实。 - -本设计不会识别或迁移任何未合入的 Schedule 实现或私有存储格式。固定 Session id、claim-before-send record、startup miss 与私有数据库都不是兼容输入。 +**接管既有根或注册全局工具。** 晚接管会让插件加载顺序激活不可见的 timer,并把工具暴露到受支持的根组合之外。 ## 验证 -package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、offset 与 local-calendar profile、IANA 校验、gap 拒绝、overlap-first 选择、mismatch confirmation、时间边界、有界等待、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败、可选 header round-trip、一次真实 SQLite v13 到 v14 migration,以及 production JSONL restart。组装后的 Loader/Web restart lane 证明 pending 恢复、fork 隔离、单次 durable dispatch、无需激活 agent 的 cold-history rendering,以及再次 restart 后不重投。Host/client 测试覆盖 live、stored 与 concurrent-create 路径中的 zone identity、逐操作提示词 provenance、commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。 - -Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、精确 snapshot 来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,在空的续跑中复用同 turn 标记,并在缺少 open-turn 标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对既有的短 `after` case 和一个 absolute-time case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。 +包测试以逐文件 100% coverage 固定严格回放、转换、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳的 dispose。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。Host/client 测试固定浏览器时区采样与绑定到提示词的校验。无密钥组装 Web 场景会驱动一条真实浏览器提示词经过 time-context,发出带显式 `time_zone` 的模型 `schedule_create` 调用,执行持久 dispatch,并产生一个没有回执 UI 的普通 assistant follow-up。 ## 后果 -- 提醒状态通过普通 Session persistence 跨进程重启并回放,无需新数据库或公开 service。 -- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 提醒,且每个工具/卡片都会显示 `session-local`。 -- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。长等待和插件卸载不会创建第二套持久状态机。 -- Session 的默认时区不可变,且在较旧 history 中可能始终不可用。因此,旅行或并发 tab 可能需要显式时区,而不是悄然改变“明天 09:00”的含义。 -- 通用 commit-aware event-view 路径可供其他持久 event 复用,但为 client Session window 增加了事件身份检查与 generation-aware merge 行为。 -- 严格的一次性协议覆盖延迟目标和绝对时间目标。周期性规则系列仍需要显式 transition、catch-up 与 model-budget 语义,而不是休眠字段。 +- 提醒状态通过普通 Session persistence 跨重启存活,无需新数据库或公开 service。 +- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 工作。 +- 无需持久 Session 时区状态或从 Schedule 到 time-context 的依赖,绝对时间输入仍然具有确定性。 +- 用户看到普通对话输出;dispatch 绝不会夸大模型成功或 acknowledgement。 +- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。 +- 周期性规则需要显式的状态转换、追赶和模型预算语义,而不是休眠字段;cron 仍在此产品边界之外。 diff --git a/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.i18n.yaml new file mode 100644 index 0000000000..713c7e676b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md +2026-08-09-explicit-schedule-time-zone.md: fd8a09df4c6f8b0003e6e01f48510ecb28d7e568 +2026-08-09-explicit-schedule-time-zone.zh.md: 3a840edda83edf70e65d6acf6a8874d1d47b09b5 diff --git a/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md b/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md new file mode 100644 index 0000000000..fd8a09df4c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md @@ -0,0 +1,47 @@ +# Agent Note: Explicit Schedule time-zone boundary + +Status: implemented + +English | [中文](2026-08-09-explicit-schedule-time-zone.zh.md) + +## Problem + +Implicit local `at` input made a browser fact into shared product state. Capturing a default zone on Session creation required new Session headers, create/resume/fork conflict rules, JSONL metadata, a SQLite migration, client creation plumbing, Host comparisons, and Schedule logic coupled to time-context markers. Travel, concurrent tabs, missing provenance, and old Sessions then needed a confirmation protocol merely to decide whether an omitted field was safe. + +Most of that complexity sat outside Schedule. The model already interprets natural language before it calls the tool, so a durable Session default duplicated an assumption instead of strengthening the absolute-time boundary. + +## Decision + +Browser zone is request-local provenance. The Web client samples `Intl.DateTimeFormat().resolvedOptions().timeZone` for every prompt. The Host accepts an optional `clientTimeZone`, validates and canonicalizes `UTC` or an IANA Area/Location at the RPC boundary, and logs it on that exact `user-rpc` message. Invalid values reject prompt admission. Non-browser clients may omit it. + +Time-context derives unique, mixed, or missing browser facts from original user-rpc messages in the open turn. A unique zone formats the clock and tells the model to interpret otherwise-unqualified dates and times in that zone. Mixed or missing provenance tells the model to ask the user. The configured or process zone is only a display fallback and is never presented as user authority. + +Schedule accepts no implicit local zone. `at` is either a strict offset-bearing RFC 3339 string or exact `{ date, time, time_zone }`. The structured form requires its zone even when time-context just showed the model a browser zone. Schedule does not import time-context, inspect user-message provenance, read a Session header, or produce a confirmation error. Its parser validates the explicit value, rejects daylight-saving gaps, chooses the first instant in overlaps, and stores only canonical UTC `scheduledAt`. + +No Session time-zone field, create/resume/fork zone conflict, JSONL header field, SQLite column or migration, connection default, or Schedule-specific Host/client presentation remains. The browser assumption crosses into Schedule only through the model's explicit tool arguments. + +## Alternatives considered + +**Persist the first browser zone as an immutable Session default.** This makes later local input deterministic but spreads ownership across core and persistence, while travel and concurrent tabs still require mismatch handling. + +**Use the most recent browser zone as mutable Session state.** This reduces confirmation prompts but lets one tab silently change another tab's interpretation and makes replay depend on update ordering. + +**Let Schedule inspect the latest time-context message.** A prose snapshot is model-visible evidence, not a typed package seam. Consuming it would couple Schedule to AgentLoop history and duplicate validation against original provenance. + +**Let the Host inject `time_zone` into tool calls.** The Host cannot know which natural-language expression the model interpreted or whether the user named another zone. Rewriting model arguments hides meaning at the wrong boundary. + +**Require the model to ask on every unqualified time.** This is safe but unnecessarily interrupts the common browser-local case. The request-local instruction provides the intended assumption while mixed or missing provenance still asks. + +## Verification + +Host tests pin canonical aliases, omission, and rejection before Agent entry. Client tests pin one browser-zone sample on each prompt. Time-context tests pin unique, mixed, and missing current-turn derivation and exact model policy. Schedule tests pin required `time_zone`, strict offsets, calendar validation, canonical zones, gap rejection, overlap-first selection, and absence of an implicit context path. The assembled Web scenario fixes Playwright to `Asia/Shanghai`, sends through the real composer, observes the same zone in the model request, verifies an explicit local tool call, and snapshots the ordinary reminder response. + +Source audits reject `SessionHeader.timeZone`, persistence `time_zone` columns, confirmation errors, Schedule imports of time-context, and independent receipt machinery. + +## Consequences + +- Browser-local natural language works without a persisted Session-zone subsystem. +- Schedule has one explicit, independently testable absolute-time boundary. +- Travel and concurrent tabs affect only their own prompts; a turn with mixed provenance asks instead of mutating shared state. +- Non-browser clients remain valid but must provide enough natural-language context or explicit tool arguments. +- The model may still make an interpretation error; the tool guarantees only that the explicit calendar value is valid and deterministic. diff --git a/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md b/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md new file mode 100644 index 0000000000..3a840edda8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 显式 Schedule 时区边界 + +Status: implemented + +[English](2026-08-09-explicit-schedule-time-zone.md) | 中文 + +## 问题 + +隐式本地 `at` 输入把浏览器事实变成了共享产品状态。在 Session 创建时捕获默认时区,需要增加新的 Session header、create/resume/fork 冲突规则、JSONL metadata、SQLite migration、client 创建 plumbing、Host 比较,以及与 time-context 标记耦合的 Schedule 逻辑。随后,旅行、并发 tab、缺失 provenance 和旧 Session 都需要一套确认协议,仅仅为了判断省略字段是否安全。 + +大部分复杂度都位于 Schedule 之外。模型在调用工具前已经解释自然语言,因此持久 Session 默认值只是重复了一个假设,并没有强化绝对时间边界。 + +## 决策 + +浏览器时区是请求本地的 provenance。Web client 会为每条提示词采样 `Intl.DateTimeFormat().resolvedOptions().timeZone`。Host 接受可选的 `clientTimeZone`,在 RPC 边界校验并规范化 `UTC` 或 IANA Area/Location,再将其记录在确切的那条 `user-rpc` 消息上。无效值会使提示词准入被拒绝。非浏览器 client 可以省略它。 + +Time-context 从 open turn 中的原始 user-rpc 消息派生唯一、混合或缺失的浏览器事实。唯一时区会用于格式化时钟,并告诉模型把未明确限定时区的日期和时间解释为该时区。provenance 混合或缺失时,模型会被告知询问用户。配置或进程时区只作为显示 fallback,绝不会被呈现为用户权威。 + +Schedule 不接受隐式本地时区。`at` 要么是带显式偏移量且严格符合 RFC 3339 的字符串,要么是精确的 `{ date, time, time_zone }`。即使 time-context 刚向模型展示了浏览器时区,结构化形式仍要求自己的时区。Schedule 不导入 time-context、不检查 user message provenance、不读取 Session header,也不产生确认错误。它的 parser 会校验显式值、拒绝夏令时缺口、在重叠时选择第一个时点,并且只存储规范化后的 UTC `scheduledAt`。 + +不再保留 Session 时区字段、create/resume/fork 时区冲突、JSONL header 字段、SQLite column 或 migration、连接默认值,也不再保留 Schedule 专属的 Host/client 呈现。浏览器假设只会通过模型的显式工具参数跨入 Schedule。 + +## 已考虑的替代方案 + +**把第一个浏览器时区持久化为不可变的 Session 默认值。** 这会使后续本地输入具有确定性,却把归属扩散到 core 和 persistence;旅行与并发 tab 仍然需要不匹配处理。 + +**把最近的浏览器时区用作可变 Session 状态。** 这会减少确认提示,却允许一个 tab 悄然改变另一个 tab 的解释,并使回放依赖更新顺序。 + +**让 Schedule 检查最新的 time-context 消息。** prose snapshot(文本快照)是模型可见证据,而不是有类型的包 seam。消费它会使 Schedule 与 AgentLoop history 耦合,并针对原始 provenance 重复校验。 + +**让 Host 向工具调用注入 `time_zone`。** Host 无法知道模型解释的是哪个自然语言表达式,也无法知道用户是否指定了另一个时区。重写模型参数会在错误的边界隐藏含义。 + +**要求模型对每个未限定时区的时间都询问用户。** 这样做是安全的,却会不必要地打断常见的浏览器本地场景。请求本地指令提供预期假设,而 provenance 混合或缺失时仍会询问用户。 + +## 验证 + +Host 测试固定别名的规范化、可省略行为和进入 Agent(智能体)前的拒绝。client 测试固定每条提示词进行一次浏览器时区采样。Time-context 测试固定当前 turn 中唯一、混合与缺失情况的派生,以及精确模型策略。Schedule 测试固定必需的 `time_zone`、严格偏移量、日历校验、规范时区、缺口拒绝、重叠时选择第一个时点,以及不存在隐式上下文路径。组装 Web 场景把 Playwright 固定到 `Asia/Shanghai`,通过真实 composer 发送提示词,在模型请求中观察同一时区,验证显式本地工具调用,并对普通提醒响应执行 snapshot。 + +源代码审计会拒绝 `SessionHeader.timeZone`、persistence `time_zone` column、确认错误、Schedule 对 time-context 的导入,以及独立回执机制。 + +## 后果 + +- 无需持久 Session 时区子系统,浏览器本地自然语言也能工作。 +- Schedule 具有一个显式且可独立测试的绝对时间边界。 +- 旅行与并发 tab 只影响各自的提示词;provenance 混合的 turn 会询问用户,而不是改变共享状态。 +- 非浏览器 client 仍然有效,但必须提供足够的自然语言上下文或显式工具参数。 +- 模型仍可能产生解释错误;工具只保证显式日历值有效且具有确定性。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 365f5c4aa7..233be23311 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -24,8 +24,8 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", - "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 99f56392e3..791f8e98c1 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -41,7 +41,7 @@ describe('web e2e: the composer model switch is the default for later sessions', const createSession = async (sessionId: string): Promise => { const response = await scaffold.ctx.apiProxy.sessions.create({ rpcId: `default-model-create-${sessionId}` as never, - payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd, timeZone: 'UTC' }, + payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd }, }) if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`) return response.result.value.sessionId @@ -150,7 +150,6 @@ describe('web e2e: the composer model switch is the default for later sessions', sessionId: SessionId(await createSession('default-model-refusal')), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], - clientTimeZone: 'UTC', }, }) expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0cd3c34df2..31a638c12e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -196,8 +196,6 @@ export interface LaunchOptions { paceMs?: number /** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */ replayContextWindow?: number - /** Caller-owned keyless adapter for a fixture that must derive its response at stream time. */ - fixtureAdapter?: LlmAdapter /** * Tool presentation mode patched onto the shipped `tools` row (`code` * collapses the wire to run_code + the SDK prompt section). Omit for the @@ -266,11 +264,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ctx.llm.registerAdapter( replayProviders(options.replayContextWindow).map(provider => provider.id), - options.fixtureAdapter ?? new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), - ), 'web e2e scaffold: fixture adapter') + new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), + ), 'web e2e scaffold: route-only adapter') } } catch (error) { if (process.cwd() !== originalCwd) process.chdir(originalCwd) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 1b32255a94..d116e285b6 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,10 +1,5 @@ -// Keyless assembled-browser evidence for the opt-in Schedule overlay. A real -// root Agent receives schedule_create through the complete tool pipeline; the -// one-second owner path queues a best-effort followup, commits dispatch, and -// renders the Host's durability-gated reminder sidecar. A separate browser -// scenario drives local at through the real zone wire and model tool call. -import { mkdtemp, realpath, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +/** Keyless assembled-Web evidence for conversational Schedule delivery. */ + import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -12,467 +7,358 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, - launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' -import { - ScheduleId, - createAfterScheduleRecord, - foldScheduleEvents, -} from '@deepseek-ai/dsh-tool-schedule' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/schedule-after', import.meta.url)) -const RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/receipt.expected.md', import.meta.url)) -const AT_RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/at-receipt.expected.md', import.meta.url)) -const SESSION_TIME_ZONE = 'UTC' -const PROMPT = 'Check the deployment log' +const AFTER_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md') +const AT_EXPECTED = join(SNAPSHOT_DIR, 'at-conversation.expected.md') +const AFTER_PROVIDER = 'schedule-after-web-test' +const AT_PROVIDER = 'schedule-at-web-test' +const MODEL = 'reply' +const AFTER_PROMPT = 'Check the deployment log' +const AFTER_REPLY = 'Reminder: Check the deployment log.' +const AT_BROWSER_ZONE = 'Asia/Shanghai' +const AT_USER_PROMPT = 'Remind me to review the release window in a few seconds in my local time.' const AT_PROMPT = 'Review the release window' -const AT_RECEIPT_SELECTOR = '[data-schedule-reminder]:has-text("Review the release window")' +const AT_READY = 'Ready for a browser-local reminder request.' +const AT_ACK = 'Scheduled in your browser time zone.' +const AT_REPLY = 'Reminder: Review the release window.' -interface CreatedScheduleView { - id: string - kind: 'after' | 'at' - scheduledAt: string - deliveryMode: 'session-local' +/** Emit one complete assistant text response. */ +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] } -/** Deterministic model boundary that selects local at relative to its actual first request. */ +/** Deterministic model seam that turns one due reminder into ordinary assistant prose. */ +class ReminderAdapter extends LlmAdapter { + override async * stream(_options: GenerateOptions): AsyncIterable { + yield * textResponse(AFTER_REPLY) + } +} + +interface LocalAt { + readonly date: string + readonly time: string + readonly time_zone: string +} + +/** Render one future epoch as exact local calendar fields in an explicit zone. */ +function localAt(epoch: number, timeZone: string): LocalAt { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(epoch).map(part => [part.type, part.value])) as Record + return { + date: `${parts['year']}-${parts['month']}-${parts['day']}`, + time: `${parts['hour']}:${parts['minute']}:${parts['second']}`, + time_zone: timeZone, + } +} + +/** Dynamic model seam proving request-local browser context becomes an explicit At selector. */ class BrowserZoneAtAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] + selectedAt: LocalAt | undefined scheduledAt: string | undefined - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, contextWindow: 128_000 }) - } - override async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) if (this.requests.length === 1) { - const target = Math.ceil((Date.now() + 10_000) / 1_000) * 1_000 - const scheduledAt = new Date(target).toISOString() - this.scheduledAt = scheduledAt - const args = JSON.stringify({ - prompt: AT_PROMPT, - at: { date: scheduledAt.slice(0, 10), time: scheduledAt.slice(11, 19) }, - }) - const callId = CallId('schedule-at-wire-call') + yield * textResponse(AT_READY) + return + } + if (this.requests.length === 2) { + const target = Math.ceil((Date.now() + 5_000) / 1_000) * 1_000 + this.selectedAt = localAt(target, AT_BROWSER_ZONE) + this.scheduledAt = new Date(target).toISOString() + const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, at: this.selectedAt }) + const callId = CallId('schedule-at-browser-zone') yield { type: 'block-start', index: 0, blockType: 'tool-call' } yield { - type: 'tool-call-delta', index: 0, id: callId, - name: 'schedule_create', argumentsDelta: args, + type: 'tool-call-delta', + index: 0, + id: callId, + name: 'schedule_create', + argumentsDelta: argumentsJson, } yield { - type: 'block-end', index: 0, - block: { type: 'tool-call', id: callId, name: 'schedule_create', arguments: args }, + type: 'block-end', + index: 0, + block: { + type: 'tool-call', + id: callId, + name: 'schedule_create', + arguments: argumentsJson, + }, } - yield { type: 'usage', usage: { inputTokens: 256, outputTokens: 32 } } yield { type: 'finish', reason: { kind: 'tool-calls' } } return } - const text = this.requests.length === 2 - ? 'The zone-aware reminder is scheduled.' - : 'The zone-aware reminder is due.' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 128, outputTokens: 16 } } - yield { type: 'finish', reason: { kind: 'stop' } } + yield * textResponse(this.requests.length === 3 ? AT_ACK : AT_REPLY) } } -/** Wait for one in-process lifecycle fact without using test-scoped expect.poll in beforeAll. */ -async function waitForFact(read: () => boolean, timeoutMs: number): Promise { +/** Extract text from one durable assistant message. */ +function assistantText(event: Extract): string { + return event.data.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +/** Extract all model-visible text from one assembled request. */ +function requestText(options: GenerateOptions): string { + return options.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +/** Wait for one exact assistant reply and return its durable sequence. */ +async function waitForReply(handle: AgentHandle, text: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs - while (!read()) { - if (Date.now() >= deadline) throw new Error(`Schedule lifecycle fact did not arrive within ${timeoutMs}ms`) - await new Promise(resolve => setTimeout(resolve, 20)) + while (true) { + const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => ( + candidate.type === 'assistant/message' && assistantText(candidate) === text + )) + if (event !== undefined) return event.seq + if (Date.now() >= deadline) throw new Error(`assistant reply did not arrive within ${timeoutMs}ms: ${text}`) + await new Promise(resolve => setTimeout(resolve, 20)) } } -/** Give a seeded Session one completed turn so the real Host fork path can cut it. */ -function appendCompletedTurn(session: Session, prompt: string): void { - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: prompt }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) -} - -describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () => { +describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { let scaffold: WebScaffold - let agentHandle: AgentHandle + let afterHandle: AgentHandle + let atHandle: AgentHandle let browser: Browser let page: Page - let scheduleId = '' + let afterAssistantSeq = -1 + let atAssistantSeq = -1 let tripwire: ReturnType + const atAdapter = new BrowserZoneAtAdapter() beforeAll(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) - agentHandle = await scaffold.ctx.agents.create({ - sessionId: SessionId('schedule-after-web-e2e'), - meta: { cwd: scaffold.workspaceCwd, timeZone: SESSION_TIME_ZONE }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - const workspace = await scaffold.ctx.workspace.create(scaffold.workspaceCwd, 'Schedule') - await workspace.attachSession(agentHandle.agent.id) - - const created = await scaffold.ctx.tools.execute({ - signal: AbortSignal.timeout(10_000), - callId: CallId('schedule-after-create'), - name: 'schedule_create', - arguments: { prompt: PROMPT, after_seconds: 1 }, - agent: agentHandle.agent, - }) - expect(created.isError).toBe(false) - if (created.isError) throw new Error(created.error.message) - const value = created.value as unknown as CreatedScheduleView - expect(value.deliveryMode).toBe('session-local') - scheduleId = value.id - expect(scheduleId.length).toBeGreaterThan(0) - - await waitForFact(() => agentHandle.agent.session.events.some(event => - event.type === 'schedule/change' - && (event.data as { operation?: unknown }).operation === 'dispatch'), 15_000) - await expect(scaffold.ctx.sessions.flush(agentHandle.agent.session)).resolves.toBe(true) - const durable = await scaffold.ctx.sessionPersistence.inspect(agentHandle.agent.id) - expect(durable.meta).toMatchObject(agentHandle.agent.session.header) - expect({ ...durable.meta, delegationDepth: durable.meta.delegationDepth ?? 0 }).toEqual({ - ...agentHandle.agent.session.header, - delegationDepth: agentHandle.agent.session.header.delegationDepth ?? 0, - }) - expect(durable.events).toEqual(agentHandle.agent.session.events.slice(0, durable.events.length)) - const history = await scaffold.ctx.apiProxy.sessions.history({ - rpcId: RpcId('schedule-history-baseline'), payload: { sessionId: agentHandle.agent.id }, - }) - if (!history.result.ok) throw new Error(history.result.error.message) - expect(history.result.value.events?.find(entry => - entry.event.type === 'schedule/change' - && (entry.event.data as { operation?: unknown }).operation === 'dispatch')?.view).toMatchObject({ - for: 'event', - }) - await waitForFact( - () => agentHandle.agent.session.events.some(event => event.type === 'turn/start'), - 10_000, + scaffold.ctx.effect( + () => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], new ReminderAdapter()), + 'Schedule Web After adapter', + ) + scaffold.ctx.effect( + () => scaffold.ctx.llm.registerAdapter([AT_PROVIDER], atAdapter), + 'Schedule Web At adapter', ) - await waitForFact(() => agentHandle.agent.session.events.some(event => - event.type === 'user/message' - && (event.data as { source?: { plugin?: unknown } }).source?.plugin === 'time-context'), 10_000) - const timeReading = agentHandle.agent.session.events.find(event => - event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'time-context') - if (timeReading?.type !== 'user/message') throw new Error('missing time-context reading') - const timeText = timeReading.data.content.find(block => block.type === 'text')?.text - if (timeText === undefined) throw new Error('missing time-context text') - expect(timeReading.data.source).toEqual({ - kind: 'plugin', - plugin: 'time-context', - form: 'snapshot', - sections: [{ name: 'time-context', text: timeText }], - }) - expect(timeText).toContain(`Session time zone: ${SESSION_TIME_ZONE}.`) - expect(timeText).toContain('Client time zone for this request: missing.') - const listed = await scaffold.ctx.apiProxy.sessions.list({ - rpcId: RpcId('schedule-list-baseline'), payload: {}, - }) - if (!listed.result.ok) throw new Error(listed.result.error.message) - expect(listed.result.value.items.find(item => item.sessionId === agentHandle.agent.id)?.blank).toBe(false) - browser = await chromium.launch() - page = await newEnglishPage(browser) - tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - const failures: unknown[] = [] - await browser?.close().catch((error: unknown) => failures.push(error)) - await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'Schedule Web evidence teardown failed') - }) - - it('renders the committed reminder from attached history', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after')) - const group = page.locator('[role="treeitem"]').first() - await group.waitFor({ timeout: 15_000 }) - // Startup auto-selection can race the first disclosure gesture. Converge - // on the expanded state instead of letting that later update collapse it. - await expect.poll(async () => { - if (await group.getAttribute('aria-expanded') !== 'true') { - await group.click() - await page.waitForTimeout(50) - } - return await group.getAttribute('aria-expanded') - }, { timeout: 5_000 }).toBe('true') - const session = page.locator('[role="treeitem"][aria-selected]').nth(1) - await session.waitFor({ timeout: 10_000 }) - await session.click() - - const receipt = page.locator('[data-schedule-reminder]') - await receipt.waitFor({ timeout: 15_000 }) - expect(await receipt.getByText(PROMPT, { exact: true }).count()).toBe(1) - expect(await receipt.getByText('Delivered in this session only', { exact: true }).count()).toBe(1) - const snapshot = (await captureStableAria(page, '[data-schedule-reminder]', scaffold.workspaceCwd)) - .split(scheduleId).join('{{scheduleId}}') - .replace(/\d{4}-\d{2}-\d{2}T(?:\d{2}:\d{2}:\d{2}\.\d{3}|\{\{clock\}\})Z/gu, '{{occurrenceAt}}') - await compareOrRefreshGolden(RECEIPT_EXPECTED, snapshot, MODE) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 60_000) - - it('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['at-receipt.expected.md', 'receipt.expected.md']) - }) -}) - -describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - const adapter = new BrowserZoneAtAdapter() - - beforeAll(async () => { - scaffold = await launchWebScaffold({ - extraOverlayPath: OVERLAY, - fixtureAdapter: adapter, - }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', - timezoneId: SESSION_TIME_ZONE, + timezoneId: AT_BROWSER_ZONE, }) await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page, scaffold.workspaceCwd, 'schedule-at-wire-e2e') + await connectFreshWorkspace(page, scaffold.workspaceCwd) + expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)) + .toBe(AT_BROWSER_ZONE) + + const cwd = join(scaffold.workspaceCwd, 'workspace') + const workspace = await scaffold.ctx.workspace.resolveByPath(cwd) + if (workspace === undefined) throw new Error('connected Web workspace was not registered') + + afterHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('schedule-after-web-e2e'), + meta: { cwd }, + agentOptions: { provider: AFTER_PROVIDER, model: MODEL }, + }) + afterHandle.agent.session.append('session/title', { + title: 'Scheduled After follow-up', + messageSeqs: [], + source: { kind: 'user' }, + }) + await workspace.attachSession(afterHandle.agent.id) + const afterCreated = await scaffold.ctx.tools.execute({ + signal: AbortSignal.timeout(10_000), + callId: CallId('schedule-after-create'), + name: 'schedule_create', + arguments: { prompt: AFTER_PROMPT, after_seconds: 1 }, + agent: afterHandle.agent, + }) + expect(afterCreated.isError).toBe(false) + afterAssistantSeq = await waitForReply(afterHandle, AFTER_REPLY, 15_000) + await afterHandle.agent.whenIdle() + await expect(scaffold.ctx.sessions.flush(afterHandle.agent.session)).resolves.toBe(true) + + atHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('schedule-at-web-e2e'), + meta: { cwd }, + agentOptions: { provider: AT_PROVIDER, model: MODEL }, + }) + atHandle.agent.session.append('session/title', { + title: 'Explicit local-time reminder', + messageSeqs: [], + source: { kind: 'user' }, + }) + atHandle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Prepare the reminder test session.' }], + source: { kind: 'plugin', plugin: 'schedule-web-e2e' }, + })) + await atHandle.agent.whenIdle() + expect(atAdapter.requests).toHaveLength(1) + await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true) + await workspace.attachSession(atHandle.agent.id) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const workspaceItem = page.locator('[role="treeitem"]').first() + await workspaceItem.waitFor({ timeout: 15_000 }) + const expansionDeadline = Date.now() + 5_000 + while (await workspaceItem.getAttribute('aria-expanded') !== 'true') { + if (Date.now() >= expansionDeadline) throw new Error('workspace item did not expand') + if (await workspaceItem.getAttribute('aria-expanded') !== 'true') { + await workspaceItem.click() + } + await new Promise(resolve => setTimeout(resolve, 50)) + } + const atSession = page.getByRole('treeitem', { name: /Explicit local-time reminder/ }) + await atSession.waitFor({ timeout: 15_000 }) + await atSession.click() + const composer = page.locator('textarea:enabled').last() + await composer.fill(AT_USER_PROMPT) + const settled = scaffold.whenTurnSettled(60_000) + await page.getByRole('button', { name: 'Send message', exact: true }).click() + expect(await settled).toBe(atHandle.agent.id) + await page.getByText(AT_ACK, { exact: true }).waitFor({ timeout: 15_000 }) + atAssistantSeq = await waitForReply(atHandle, AT_REPLY, 20_000) + await atHandle.agent.whenIdle() + await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true) }, 120_000) afterAll(async () => { const failures: unknown[] = [] await browser?.close().catch((error: unknown) => failures.push(error)) + await atHandle?.dispose().catch((error: unknown) => failures.push(error)) + await afterHandle?.dispose().catch((error: unknown) => failures.push(error)) await scaffold?.close().catch((error: unknown) => failures.push(error)) if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'Schedule at wire evidence teardown failed') + if (failures.length > 1) throw new AggregateError(failures, 'Schedule Web evidence teardown failed') }) - it('carries the browser zone through prompt context, local at, and the durable receipt', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at-wire')) - const composer = page.locator('textarea:enabled').last() - await composer.fill('Schedule the release-window reminder in my local time.') - const settled = scaffold.whenTurnSettled(60_000) - await page.getByRole('button', { name: 'Send message', exact: true }).click() - const sessionId = await settled - const agent = scaffold.ctx.agents.get(sessionId) - if (agent === undefined) throw new Error('browser-created Schedule Session has no live Agent') - expect(agent.session.header.timeZone).toBe(SESSION_TIME_ZONE) + it('renders After as an ordinary assistant follow-up', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after')) + const session = page.getByRole('treeitem', { name: /Scheduled After follow-up/ }) + await session.click() + const selector = `[data-chat-anchor-key="node:${String(afterAssistantSeq)}"]` + const row = page.locator(selector) + await row.waitFor({ timeout: 15_000 }) + expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await row.textContent()).toContain(AFTER_REPLY) + await compareOrRefreshGolden( + AFTER_EXPECTED, + await captureStableAria(page, selector, scaffold.workspaceCwd), + MODE, + ) + expect(await page.locator('[data-schedule-reminder]').count()).toBe(0) + }, 60_000) - const request = agent.session.events.find(event => + it('uses request-local browser context to create an explicit local At reminder', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at')) + const user = atHandle.agent.session.events.find(event => ( event.type === 'user/message' && event.data.source.kind === 'user' - && event.data.content.some(block => block.type === 'text' - && block.text === 'Schedule the release-window reminder in my local time.')) - if (request?.type !== 'user/message' || request.data.source.kind !== 'user') { + && event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT) + )) + if (user?.type !== 'user/message' || user.data.source.kind !== 'user') { throw new Error('missing browser user-rpc message') } - expect(request.data.source).toMatchObject({ - kind: 'user', - clientTimeZone: SESSION_TIME_ZONE, - }) - expect(typeof (request.data.source as { rpcId?: unknown }).rpcId).toBe('string') + expect(user.data.source).toMatchObject({ kind: 'user', clientTimeZone: AT_BROWSER_ZONE }) + expect(typeof (user.data.source as { rpcId?: unknown }).rpcId).toBe('string') - const timeContextIndex = agent.session.events.findIndex(event => - event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'time-context' - && event.data.content.some(block => block.type === 'text' - && block.text.includes('Session time zone: UTC.') - && block.text.includes('Client time zone for this request: UTC.'))) - const toolCallIndex = agent.session.events.findIndex(event => - event.type === 'tool/call' && event.data.name === 'schedule_create') - expect(timeContextIndex).toBeGreaterThanOrEqual(0) - expect(toolCallIndex).toBeGreaterThan(timeContextIndex) - - const firstRequest = adapter.requests[0] + const firstRequest = atAdapter.requests[1] if (firstRequest === undefined) throw new Error('model did not receive the browser prompt') - expect(JSON.stringify(firstRequest.messages)).toContain('Session time zone: UTC.') - expect(JSON.stringify(firstRequest.messages)).toContain('Client time zone for this request: UTC.') + expect(requestText(firstRequest)).toContain( + `Browser time zone for this request: ${AT_BROWSER_ZONE}. ` + + 'Interpret otherwise-unqualified dates and times in this zone.', + ) expect(firstRequest.tools?.some(tool => tool.name === 'schedule_create')).toBe(true) + const selectedAt = atAdapter.selectedAt + const scheduledAt = atAdapter.scheduledAt + if (selectedAt === undefined || scheduledAt === undefined) { + throw new Error('model did not choose an explicit local At target') + } + expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE) - const scheduledAt = adapter.scheduledAt - if (scheduledAt === undefined) throw new Error('model did not choose a local at target') - const created = agent.session.events.find(event => + const toolCall = atHandle.agent.session.events.find(event => ( + event.type === 'tool/call' && event.data.name === 'schedule_create' + )) + if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call') + expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt }) + const created = atHandle.agent.session.events.find(event => ( event.type === 'schedule/change' && event.data.operation === 'create' && event.data.schedule.kind === 'at' - && event.data.schedule.scheduledAt === scheduledAt) + )) if (created?.type !== 'schedule/change' || created.data.operation !== 'create') { - throw new Error('local at tool call did not create its durable record') + throw new Error('explicit local At call did not create a durable record') } - const scheduleId = created.data.schedule.id - await waitForFact(() => agent.session.events.some(event => + const schedule = created.data.schedule + expect(schedule).toMatchObject({ + kind: 'at', + prompt: AT_PROMPT, + scheduledAt, + }) + expect(atHandle.agent.session.events.filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' - && event.data.id === scheduleId), 20_000) - await agent.whenIdle() - expect(adapter.requests).toHaveLength(3) - await expect(scaffold.ctx.sessions.flush(agent.session)).resolves.toBe(true) + && event.data.id === schedule.id + ))).toHaveLength(1) + expect(atAdapter.requests).toHaveLength(4) - const history = await scaffold.ctx.apiProxy.sessions.history({ - rpcId: RpcId('schedule-at-wire-history'), - payload: { sessionId }, - }) - if (!history.result.ok) throw new Error(history.result.error.message) - expect(history.result.value.events?.find(entry => - entry.event.type === 'schedule/change' - && entry.event.data.operation === 'dispatch' - && entry.event.data.id === scheduleId)?.view).toMatchObject({ - for: 'event', - view: { scheduleId, prompt: AT_PROMPT, occurrenceAt: scheduledAt }, - }) - - const receipt = page.locator(AT_RECEIPT_SELECTOR) - await receipt.waitFor({ timeout: 20_000 }) - const snapshot = (await captureStableAria(page, AT_RECEIPT_SELECTOR, scaffold.workspaceCwd)) - .split(scheduleId).join('{{scheduleId}}') - .replace(/\d{4}-\d{2}-\d{2}T(?:\d{2}:\d{2}:\d{2}\.\d{3}|\{\{clock\}\})Z/gu, '{{occurrenceAt}}') - await compareOrRefreshGolden(AT_RECEIPT_EXPECTED, snapshot, MODE) + const session = page.getByRole('treeitem', { name: /Explicit local-time reminder/ }) + await session.click() + const selector = `[data-chat-anchor-key="node:${String(atAssistantSeq)}"]` + const row = page.locator(selector) + await row.waitFor({ timeout: 15_000 }) + expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await row.textContent()).toContain(AT_REPLY) + await compareOrRefreshGolden( + AT_EXPECTED, + await captureStableAria(page, selector, scaffold.workspaceCwd), + MODE, + ) + expect(await page.locator('[data-schedule-reminder]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) -}) - -describe.skipIf(MODE === 'record')('web e2e: Schedule restart, fork, and cold history', () => { - it('preserves pending work, commits one overdue receipt, and replays it cold without activation', async () => { - const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-schedule-restart-ws-'))) - const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-schedule-restart-sessions-')) - const world = { workspaceCwd, persistenceRoot } - const pendingId = SessionId('schedule-restart-pending') - const deliveredId = SessionId('schedule-restart-delivered') - let scaffold: WebScaffold | undefined - try { - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) - const workspace = await scaffold.ctx.workspace.create(workspaceCwd, 'Schedule restart') - - const pending = scaffold.ctx.sessions.create(pendingId, { meta: { cwd: workspaceCwd } }) - appendCompletedTurn(pending, 'pending parent turn') - pending.append('session/title', { - title: 'Pending restart session', messageSeqs: [], source: { kind: 'user' }, - }) - const pendingRecord = createAfterScheduleRecord( - ScheduleId('schedule-pending'), 'Pending across restart', 3_600, Date.now(), - ) - pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord }) - await expect(scaffold.ctx.sessions.flush(pending)).resolves.toBe(true) - await workspace.attachSession(pendingId) - - const delivered = scaffold.ctx.sessions.create(deliveredId, { meta: { cwd: workspaceCwd } }) - appendCompletedTurn(delivered, 'delivered parent turn') - delivered.append('session/title', { - title: 'Delivered restart session', messageSeqs: [], source: { kind: 'user' }, - }) - const overdueRecord = createAfterScheduleRecord( - ScheduleId('schedule-delivered'), 'Delivered after restart', 1, Date.now() - 60_000, - ) - delivered.append('schedule/change', { version: 1, operation: 'create', schedule: overdueRecord }) - await expect(scaffold.ctx.sessions.flush(delivered)).resolves.toBe(true) - await workspace.attachSession(deliveredId) - - await scaffold.close() - scaffold = undefined - - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) - const pendingResume = await scaffold.ctx.apiProxy.sessions.create({ - rpcId: RpcId('schedule-pending-resume'), - payload: { sessionId: pendingId, cwd: workspaceCwd, timeZone: 'UTC' }, - }) - if (!pendingResume.result.ok) throw new Error(pendingResume.result.error.message) - const pendingAgent = scaffold.ctx.agents.get(pendingId) - if (pendingAgent === undefined) throw new Error('pending Session did not resume') - expect(foldScheduleEvents( - pendingAgent.session.events, - pendingAgent.session.header.seedLength ?? 0, - ).active).toEqual([expect.objectContaining({ id: 'schedule-pending' })]) - - const forked = await scaffold.ctx.apiProxy.sessions.fork({ - rpcId: RpcId('schedule-pending-fork'), - payload: { sessionId: pendingId }, - }) - if (!forked.result.ok) throw new Error(forked.result.error.message) - const child = scaffold.ctx.agents.get(forked.result.value.sessionId) - if (child === undefined) throw new Error('fork child was not published') - expect(foldScheduleEvents( - child.session.events, - child.session.header.seedLength ?? 0, - ).active).toEqual([]) - - const deliveredResume = await scaffold.ctx.apiProxy.sessions.create({ - rpcId: RpcId('schedule-delivered-resume'), - payload: { sessionId: deliveredId, cwd: workspaceCwd, timeZone: 'UTC' }, - }) - if (!deliveredResume.result.ok) throw new Error(deliveredResume.result.error.message) - const deliveredAgent = scaffold.ctx.agents.get(deliveredId) - if (deliveredAgent === undefined) throw new Error('overdue Session did not resume') - await waitForFact(() => deliveredAgent.session.events.some(event => - event.type === 'schedule/change' && event.data.operation === 'dispatch'), 15_000) - await deliveredAgent.whenIdle() - await expect(scaffold.ctx.sessions.flush(deliveredAgent.session)).resolves.toBe(true) - expect(deliveredAgent.session.events.filter(event => - event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) - - await scaffold.close() - scaffold = undefined - - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) - expect(scaffold.ctx.agents.get(deliveredId)).toBeUndefined() - const coldHistory = await scaffold.ctx.apiProxy.sessions.history({ - rpcId: RpcId('schedule-cold-history'), - payload: { sessionId: deliveredId }, - }) - if (!coldHistory.result.ok) throw new Error(coldHistory.result.error.message) - const dispatchEntries = coldHistory.result.value.events.filter(entry => - entry.event.type === 'schedule/change' - && entry.event.data.operation === 'dispatch') - expect(dispatchEntries).toHaveLength(1) - expect(dispatchEntries[0]?.view?.for).toBe('event') - expect(scaffold.ctx.agents.get(deliveredId)).toBeUndefined() - - await scaffold.close() - scaffold = undefined - - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, world }) - const replayed = await scaffold.ctx.apiProxy.sessions.create({ - rpcId: RpcId('schedule-delivered-replay'), - payload: { sessionId: deliveredId, cwd: workspaceCwd, timeZone: 'UTC' }, - }) - if (!replayed.result.ok) throw new Error(replayed.result.error.message) - const replayedAgent = scaffold.ctx.agents.get(deliveredId) - if (replayedAgent === undefined) throw new Error('delivered Session did not resume again') - await replayedAgent.whenIdle() - await expect(scaffold.ctx.sessions.flush(replayedAgent.session)).resolves.toBe(true) - expect(replayedAgent.session.events.filter(event => - event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) - } finally { - const failures: unknown[] = [] - await scaffold?.close().catch((error: unknown) => failures.push(error)) - await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) - await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'Schedule restart evidence teardown failed') - } - }, 180_000) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'at-conversation.expected.md', + 'conversation.expected.md', + ]) + }) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 8bf0490b31..f32daebfe4 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -27,7 +27,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts' const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url)) -const WEB_TIME_ZONE = 'UTC' function waitForReadyLine(child: ChildProcess): Promise { return new Promise((resolveReady, reject) => { @@ -242,14 +241,11 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { - timeZone: WEB_TIME_ZONE, - }) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], - clientTimeZone: WEB_TIME_ZONE, }) const capturedRequests = await Promise.race([ providerRequests, @@ -357,14 +353,11 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { - timeZone: WEB_TIME_ZONE, - }) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: promptMarker }], - clientTimeZone: WEB_TIME_ZONE, }) let page: HistoryPage | undefined await expect.poll(async () => { @@ -444,14 +437,11 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', { - timeZone: WEB_TIME_ZONE, - }) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], - clientTimeZone: WEB_TIME_ZONE, }) const captured = await Promise.race([ providerRequest, diff --git a/apps/web/tests/snapshots/schedule-after/at-conversation.expected.md b/apps/web/tests/snapshots/schedule-after/at-conversation.expected.md new file mode 100644 index 0000000000..194d830056 --- /dev/null +++ b/apps/web/tests/snapshots/schedule-after/at-conversation.expected.md @@ -0,0 +1,6 @@ +- paragraph: "Reminder: Review the release window." +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/schedule-after/at-receipt.expected.md b/apps/web/tests/snapshots/schedule-after/at-receipt.expected.md deleted file mode 100644 index 80859b20c6..0000000000 --- a/apps/web/tests/snapshots/schedule-after/at-receipt.expected.md +++ /dev/null @@ -1,6 +0,0 @@ -- note: - - banner: Scheduled reminder Delivered in this session only - - paragraph: Review the release window - - contentinfo: - - text: ID {{scheduleId}} - - time: Due at {{occurrenceAt}} diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 71ccd2075f..e1e35e7981 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -92,7 +92,6 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co // A live parent Agent through the real API; no workspace or browser. const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', { cwd: scaffold.workspaceCwd, - timeZone: 'UTC', }) if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`) parentId = sessionId(created.value.sessionId) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dff7978387..416277bb91 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 506283bfc651d38fe4bccd50fb8a86143d2b41d8 -architecture.zh.md: ea48af8131ebbdc70a45e403dfddc78dfcfb6f47 +architecture.md: 1c4733eeaca2bce54761440bda3cee0ed9ab4c32 +architecture.zh.md: ab6eea8f7656003c728796125c64d9242a37ceb4 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index fc108f965a..c54451e18d 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 5c4b80be5159455589d48a4f185393426a198833 -config-catalog.zh.md: 4f18b898f57e1c135e20e51544aa08c1127e547b +config-catalog.md: 11618b725964f429e4a9852ef0323c0f4e4d08ab +config-catalog.zh.md: ba0148c7be63c65ff9f717e52b76610288e198fb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5841e6255e..11618b7259 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1877,14 +1877,14 @@ Requires: `agents` ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { - /** Fallback display zone for headerless Sessions. Omit to use the process zone. */ + /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */ timeZone?: string /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */ refreshIntervalMs?: number } ``` -Source: [`packages/context/time-context/src/index.ts:29`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:26`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4f18b898f5..ba0148c7be 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1879,14 +1879,14 @@ export interface Config { ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { - /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ + /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */ timeZone?: string /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */ refreshIntervalMs?: number } ``` -来源:[`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +来源:[`packages/context/time-context/src/index.ts:26`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 75a47b2cff..1984b028a4 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 840f935dbe982c44b11feccedca90d75a5b4c661 -event-producer-consumer.zh.md: 2b9f457168e5c4e877d5de04bc63290377f9403a +event-producer-consumer.md: d1cb21e7f8dadcb517b62580f4f5e6305e965e5a +event-producer-consumer.zh.md: bc323d3c4a6643a98f4285d6d42e92ba0a0c7017 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 840f935dbe..d1cb21e7f8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-schedule`](../packages/schedule/tool-schedule), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-schedule`](../packages/schedule/tool-schedule), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2b9f457168..bc323d3c4a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-schedule`](../packages/schedule/tool-schedule), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-schedule`](../packages/schedule/tool-schedule), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index a27084adcf..5908bc237c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 86538551c543bea208ada8682fe6c07e0f048b0a -persistence-catalog.zh.md: d8b319fd15576755d6c891cdd104b4e5f4bae3b6 +persistence-catalog.md: 597ab8f8e94daa684c5da30b3b8cf18bcc6a1782 +persistence-catalog.zh.md: 2b733ce26ed17fec99fcabc0ecc743ce179ea5e4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b4cc6e1f03..597ab8f8e9 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) ## Events @@ -175,7 +175,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -191,7 +191,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `command/*` @@ -479,7 +479,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -491,7 +491,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -526,7 +526,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'schedule/change': ScheduleChange ``` -Source: [`packages/schedule/tool-schedule/src/types.ts:202`](../packages/schedule/tool-schedule/src/types.ts) +Source: [`packages/schedule/tool-schedule/src/types.ts:183`](../packages/schedule/tool-schedule/src/types.ts) ### `session/*` @@ -558,7 +558,7 @@ Source: [`packages/schedule/tool-schedule/src/types.ts:202`](../packages/schedul 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -594,7 +594,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -603,7 +603,7 @@ Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -633,7 +633,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `tool/*` @@ -650,7 +650,7 @@ Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -723,7 +723,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) ### `turn/*` @@ -743,7 +743,7 @@ Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/ Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -757,7 +757,7 @@ Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) ### `user/*` @@ -774,7 +774,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index d8b319fd15..2b733ce26e 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -528,7 +528,7 @@ export type SessionEvent = { 'schedule/change': ScheduleChange ``` -来源:[`packages/schedule/tool-schedule/src/types.ts:144`](../packages/schedule/tool-schedule/src/types.ts) +来源:[`packages/schedule/tool-schedule/src/types.ts:183`](../packages/schedule/tool-schedule/src/types.ts) ### `session/*` diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index da4dbff0b8..991efe39a0 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -38,7 +38,7 @@ interface SessionLocation { ## `SessionHeader` — metadata beside the log -Per-session metadata travels **separately** from the event log: format version, cwd, optional caller-validated time zone, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. +Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -59,11 +59,6 @@ interface SessionHeader { readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string - /** - * Optional caller-validated time-zone identifier captured at creation. - * Session core preserves the exact string without interpreting or canonicalizing it. - */ - readonly timeZone?: string /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** @@ -87,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, optional caller-validated `timeZone`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -104,8 +99,6 @@ interface CreateSessionOptions { */ readonly meta?: { readonly cwd?: string - /** Caller-validated time-zone identifier to preserve verbatim in the header. */ - readonly timeZone?: string readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 6afb28ccf7..2391eeb2a7 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -38,7 +38,7 @@ interface SessionLocation { ## `SessionHeader`:日志旁的元数据 -每个会话的元数据与事件日志**分开**存储:格式版本、cwd、可选且由调用方校验的时区、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 +每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -59,11 +59,6 @@ interface SessionHeader { readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string - /** - * Optional caller-validated time-zone identifier captured at creation. - * Session core preserves the exact string without interpreting or canonicalizing it. - */ - readonly timeZone?: string /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** @@ -87,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、可选且由调用方校验的 `timeZone`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -104,8 +99,6 @@ interface CreateSessionOptions { */ readonly meta?: { readonly cwd?: string - /** Caller-validated time-zone identifier to preserve verbatim in the header. */ - readonly timeZone?: string readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index d692fd6e2e..a5162c77d5 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -359,8 +359,8 @@ declare class Session { /** The ordered surface over this session's event log. */ get surface(): SessionSurface; /** - * Detached, deep-frozen creation metadata (format version, cwd, time zone, - * lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index f8a19bcfca..989a3f5368 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -361,8 +361,8 @@ declare class Session { /** The ordered surface over this session's event log. */ get surface(): SessionSurface; /** - * Detached, deep-frozen creation metadata (format version, cwd, time zone, - * lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 945da210af..a222e801b7 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: fad163c41b6f645d1d5e91d3b550fa74e3a63903 -tool-catalog.zh.md: e6777f666cba84a5743cecbe2131e3d5caef88f0 +tool-catalog.md: c0e8b6e329ecd2794bc1a4f0bdf4995311e3356c +tool-catalog.zh.md: 0f4747c8dfc5142c072ce3d9c823734a747f5f84 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a1f2bd209c..c0e8b6e329 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -866,11 +866,12 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl }, "required": [ "date", - "time" + "time", + "time_zone" ] } ], - "description": "Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone." + "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." } }, "required": [ diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index e6777f666c..0f4747c8df 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -835,7 +835,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_create` -在当前会话中创建一条提醒。v1 只接受非空 prompt 和正的安全整数 after_seconds 延时。交付模式是 session-local:只有此会话处于 live 状态时,提醒才会准时运行;否则提醒会进入 overdue 状态,直至会话恢复。 +在当前会话中创建一条提醒。请提供非空 prompt 和恰好一个 selector:正的安全整数 after_seconds 延时,或作为严格带偏移日期时间或本地日期/时间对象的 at。交付模式是 session-local:只有此会话处于 live 状态时,提醒才会准时运行;否则提醒会进入 overdue 状态,直至会话恢复。 ```json { @@ -848,11 +848,38 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "after_seconds": { "type": "number", "description": "Positive safe-integer delay in seconds." + }, + "at": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "time": { + "type": "string" + }, + "time_zone": { + "type": "string" + } + }, + "required": [ + "date", + "time", + "time_zone" + ] + } + ], + "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." } }, "required": [ - "prompt", - "after_seconds" + "prompt" ] } ``` diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 88fa74850c..9fcadd71d4 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly timeZone?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"bfdb0373-388d-4f50-9ad5-7211cb4073c2"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/web-schedule/README.i18n.yaml b/examples/web-schedule/README.i18n.yaml index a13148ce5b..d660e32eb6 100644 --- a/examples/web-schedule/README.i18n.yaml +++ b/examples/web-schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/web-schedule/README.md -README.md: 303f616b90fb7b8318c37ab5eca999cd855c23c6 -README.zh.md: b9fc3b69c7f530f46f9d9776061114710d9f6f29 +README.md: b5a2067bfd217baa268965caad63234136e811d5 +README.zh.md: 69b9a244dca3cf0e47819b29028a8f70a96e8604 diff --git a/examples/web-schedule/README.md b/examples/web-schedule/README.md index 303f616b90..b5a2067bfd 100644 --- a/examples/web-schedule/README.md +++ b/examples/web-schedule/README.md @@ -8,14 +8,12 @@ This overlay opts one `dsh web` process into Schedule reminders without changing dsh web --patch examples/web-schedule/cordis.yml ``` -The current overlay supports one-shot reminders created with a positive whole-number `after_seconds` or an absolute `at` target. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`. +The current overlay supports one-shot reminders created with a positive whole-number `after_seconds` or an absolute `at` target. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies delivery as `session-local`. -An `at` target is either a strict RFC 3339 date-time with `Z` or a numeric offset, or a local `{ date, time, time_zone? }` value. The overlay loads time-context so the model sees the current date, local time, Session zone, and request-zone relationship before calling the tool. A local value may omit `time_zone` only when the current browser zone agrees with the immutable zone captured when that Session was created. +The browser attaches its IANA zone to each prompt. Time-context tells the model to interpret otherwise-unqualified dates and times in that request's browser zone. This assumption belongs to natural-language interpretation only: `schedule_create.at` must be either a strict RFC 3339 date-time with `Z` or a numeric offset, or `{ date, time, time_zone }` with an explicit `UTC` or IANA Area/Location zone. Schedule does not retain or infer a Session default zone. Daylight-saving gaps are rejected, overlaps choose the first instant, and successful records keep only the resulting UTC target. -The browser samples its zone for each create or prompt operation. Resuming the Session from another zone does not overwrite the original default: an omitted local zone then returns `timezone_confirmation_required`, and the model asks which zone to use before retrying explicitly. Older headerless Sessions behave the same way with an unavailable default. Daylight-saving gaps are rejected and overlaps choose the first instant; successful records keep only the resulting UTC target. - -The original Session log owns each reminder. A live root Agent waits and retries after it becomes idle, then queues a normal follow-up turn in that conversation. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Merely reading cold history never activates it, and a fork does not inherit its parent's reminders. +The original Session log owns each reminder. A live root Agent waits until it is fully idle, then queues a normal follow-up turn in that conversation. It never steers current work and adds no separate receipt or reminder card. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Reading cold history never activates it, and a fork does not inherit its parent's reminders. Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. Schedule does not provide browser, operating-system, email, SMS, or other external notification. A durable dispatch records that the follow-up was queued; it does not acknowledge model success or user receipt. -Fixed-interval and cron rules are not accepted by this layer. +Fixed-rate and cron rules are not supported by this version. diff --git a/examples/web-schedule/README.zh.md b/examples/web-schedule/README.zh.md index b9fc3b69c7..69b9a244dc 100644 --- a/examples/web-schedule/README.zh.md +++ b/examples/web-schedule/README.zh.md @@ -8,14 +8,12 @@ dsh web --patch examples/web-schedule/cordis.yml ``` -当前 overlay 支持使用正整数 `after_seconds` 或绝对时间 `at` 目标创建的一次性提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`。 +当前 overlay 支持使用正整数 `after_seconds` 或绝对时间 `at` 目标创建的一次性提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付标为 `session-local`。 -`at` 目标可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,也可以是本地 `{ date, time, time_zone? }` 值。此 overlay 会加载时间上下文,让模型在调用工具前看到当前日期、本地时间、Session 时区及其与请求时区的关系。只有当前浏览器时区与创建该 Session 时捕获且不可变的时区一致,本地值才可省略 `time_zone`。 +浏览器会为每条提示词附加其 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该请求的浏览器时区。此假设仅用于自然语言解释:`schedule_create.at` 必须是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,或是带显式 `UTC` 或 IANA Area/Location 时区的 `{ date, time, time_zone }`。Schedule 不保留或推断 Session 默认时区。夏令时缺口会被拒绝,重叠时段选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。 -浏览器会在每次创建或提示词操作时采样自身时区。从其他时区恢复 Session 不会覆盖原有的默认时区:此时若省略本地时区,就会返回 `timezone_confirmation_required`,模型会先询问应使用哪个时区,再显式指定该时区重试。没有标头的旧 Session 在默认时区不可用时也会采用相同行为。夏令时缺口会被拒绝,重叠时段则选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。 - -每条提醒由原 Session 日志拥有。live 根 Agent 会等待并在恢复 idle 后重试,随后在该对话中排入一个普通 follow-up 轮次。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。仅查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。 +每条提醒由原 Session 日志拥有。live 根 Agent 会等待到完全 idle,再在该对话中排入一个普通 follow-up 轮次。它绝不会中途引导当前工作,也不会添加独立回执或提醒卡片。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。 创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知。持久 dispatch 会记录 follow-up 已经入队;它不确认模型成功或用户已收到提醒。 -本层不接受固定间隔或 cron 规则。 +此版本不支持固定速率规则或 cron 规则。 diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ee21764148..53b68ca786 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -11,10 +11,7 @@ import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/ import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' const sid = (id: string): SessionId => id as SessionId -const req =

(payload: P): RpcRequest

=> ({ - rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), - payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload }, -}) +const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload }) let reqCount = 0 interface TimingHooks { @@ -758,82 +755,6 @@ describe('createFixtureApi', () => { }) }) - it('mirrors canonical Session and message-bound client zone handling', async () => { - const api = createFixtureApi({ empty: true }) - const sessionId = sid('fx-zone') - const alias = 'US/Eastern' - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) - .resolvedOptions().timeZone - - await expect(api.sessions.create(req({ sessionId, timeZone: alias }))).resolves.toMatchObject({ - result: { ok: true, value: { sessionId } }, - }) - await expect(api.sessions.create(req({ sessionId, timeZone: canonical }))).resolves.toMatchObject({ - result: { ok: true, value: { sessionId } }, - }) - const conflict = await api.sessions.create(req({ sessionId, timeZone: 'Asia/Shanghai' })) - expect(conflict.result).toMatchObject({ - ok: false, - error: { - code: 'session-conflict', - details: { - sessionId, - requestedTimeZone: 'Asia/Shanghai', - existingTimeZone: canonical, - }, - }, - }) - - const prompted = await api.sessions.prompt(req({ - sessionId, - mode: 'queue', - content: [{ type: 'text', text: 'zone-bound' }], - clientTimeZone: alias, - })) - expect(prompted.result).toMatchObject({ ok: true }) - const history = await api.sessions.history(req({ sessionId })) - if (!history.result.ok) throw new Error('fixture history failed') - const user = history.result.value.events.find(entry => entry.event.type === 'user/message') - expect(user?.event).toMatchObject({ - type: 'user/message', - data: { source: { kind: 'user', clientTimeZone: canonical } }, - }) - }) - - it.each([ - ['timeZone', undefined], - ['timeZone', 'CST'], - ['timeZone', 'Not/A_Real_Zone'], - ['clientTimeZone', undefined], - ['clientTimeZone', 'CST'], - ['clientTimeZone', 'Not/A_Real_Zone'], - ] as const)('rejects invalid fixture %s input %j', async (field, value) => { - const api = createFixtureApi({ empty: true }) - if (field === 'timeZone') { - const invalidRequest = req({}) - Object.assign(invalidRequest.payload, { timeZone: value }) - const created = await api.sessions.create(invalidRequest) - expect(created.result).toMatchObject({ - ok: false, - error: { code: 'invalid-time-zone', details: { field, value: value ?? null } }, - }) - return - } - const created = await api.sessions.create(req({ timeZone: 'UTC' })) - if (!created.result.ok) throw new Error('fixture create failed') - const invalidRequest = req({ - sessionId: created.result.value.sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'rejected' }], - }) - Object.assign(invalidRequest.payload, { clientTimeZone: value }) - const prompted = await api.sessions.prompt(invalidRequest) - expect(prompted.result).toMatchObject({ - ok: false, - error: { code: 'invalid-time-zone', details: { field, value: value ?? null } }, - }) - }) - it('attaches an existing ungrouped Session to a matching Workspace', async () => { const api = createFixtureApi() const sessionId = sid('fx-existing-ungrouped') @@ -865,12 +786,7 @@ describe('createFixtureApi', () => { error: { code: 'session-conflict', message: `session ${existing.sessionId} already uses no cwd`, - details: { - sessionId: existing.sessionId, - requestedCwd: '/tmp/fixture', - requestedTimeZone: 'UTC', - existingTimeZone: 'UTC', - }, + details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, }, }) }) @@ -1067,16 +983,11 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { { query: 'fixture' }, new AbortController().signal, )).result.ok).toBe(true) - const created = await client.sessions.create({ timeZone: 'UTC' }) + const created = await client.sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.prompt({ - sessionId: id, - mode: 'queue', - content: [{ type: 'text', text: '嗨' }], - clientTimeZone: 'UTC', - })).result.ok).toBe(true) + expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) @@ -1087,7 +998,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) if (!renamed.result.ok) throw new Error('workspace rename failed') expect(renamed.result.value.workspace.title).toBe('via-client-2') - const attached = await client.sessions.create({ workspaceId: wsid, timeZone: 'UTC' }) + const attached = await client.sessions.create({ workspaceId: wsid }) if (!attached.result.ok) throw new Error('attached create failed') const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) if (!moved.result.ok) throw new Error('workspace move failed') @@ -1147,7 +1058,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const created = await client.sessions.create({ workspaceId: made.result.value.workspace.workspaceId, sessionId, - timeZone: 'UTC', }) expect(created.result).toMatchObject({ ok: true, value: { sessionId } }) const frames = await framesPromise @@ -1156,7 +1066,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { sessionId, mode: 'queue', content: [{ type: 'text', text: 'retain' }], - clientTimeZone: 'UTC', }) expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) }) @@ -1167,7 +1076,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const partialResult = await partial.sessions.create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), - timeZone: 'UTC', }) expect(partialResult.result).toMatchObject({ ok: false, @@ -1179,7 +1087,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { await expect(dropped.sessions.create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), - timeZone: 'UTC', })).rejects.toThrow(/dropped session\.create response/) }) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index e1c07abae3..b337c45d0d 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: bd8528e97b04d5b4b28922266306969e8f19295a -README.zh.md: 9aea486fb17c5a170ee8c1195435d220b495b615 +README.md: 42fb7642cbf4f122a3c9517fb22a291eb6debe87 +README.zh.md: c798634b875570dfd49d6240ed85f6895d2dece4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index bd8528e97b..42fb7642cb 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +For each ordinary local `Session.prompt()`, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state. + ## Slot declaration injection `ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9aea486fb1..c798634b87 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,6 +4,8 @@ 客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +每次调用普通本地 `Session.prompt()` 时,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次提示词 RPC。该值既不缓存,也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。 + ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 0a5fe592da..64199c4812 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -10,7 +10,6 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' -import { resolvedClientTimeZone } from '../time-zone.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -515,10 +514,7 @@ export class SessionManager { opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, ): Promise> { try { - const shared = { - timeZone: resolvedClientTimeZone(), - ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), - } + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } diff --git a/packages/client/runtime/src/client/time-zone.ts b/packages/client/runtime/src/client/time-zone.ts index 56376d1a77..9c2ddc4ea2 100644 --- a/packages/client/runtime/src/client/time-zone.ts +++ b/packages/client/runtime/src/client/time-zone.ts @@ -1,4 +1,4 @@ -/** Browser-owned time-zone sampling for Session and prompt RPC provenance. */ +/** Browser-owned time-zone sampling for prompt RPC provenance. */ /** * Resolve the current browser IANA zone for one outbound operation. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 66572e75a4..b700c4c066 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -12,11 +12,8 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' -import { resolvedClientTimeZone } from '../src/client/time-zone.ts' import { FakeApiClient, ok } from './fake-api.ts' -const CLIENT_TIME_ZONE = resolvedClientTimeZone() - interface Bench { ctx: Context api: FakeApiClient @@ -105,10 +102,7 @@ describe('runtime client apply', () => { const sessions = bench.ctx.get('sessions') as SessionsService const workspaces = bench.ctx.get('workspaces') as WorkspacesService - expect(bench.api.callsOf('session.create')).toEqual([{ - workspaceId: 'w-recent', - timeZone: CLIENT_TIME_ZONE, - }]) + expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }]) expect(sessions.list.getSnapshot().current).toBe('fk-new') sessions.clear() diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2334760496..c69465df45 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -6,13 +6,11 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { resolvedClientTimeZone } from '../src/client/time-zone.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId -const CLIENT_TIME_ZONE = resolvedClientTimeZone() type SummaryOver = Partial<{ updatedAt: number @@ -710,11 +708,7 @@ describe('remaining branches', () => { api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) const manager = new SessionManager(api) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) - expect(api.callsOf('session.create')).toEqual([{ - cwd: '/tmp/w', - sessionId: S1, - timeZone: CLIENT_TIME_ZONE, - }]) + expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 21195ec807..cfcc20e5c3 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -11,7 +11,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' -import { resolvedClientTimeZone } from '../src/client/time-zone.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' @@ -20,7 +19,6 @@ const at = (seq: number, e: Record): SessionEvent => const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId -const CLIENT_TIME_ZONE = resolvedClientTimeZone() afterEach(() => { vi.unstubAllGlobals() @@ -724,11 +722,11 @@ describe('prompt and cancel errors', () => { expect(result.ok).toBe(true) // Monotone: settlement alone does not step the phase anywhere. expect(session.getSnapshot().composerPhase).toBe('engaging') - expect(api.callsOf('session.prompt')).toEqual([{ + expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }], - clientTimeZone: CLIENT_TIME_ZONE, + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, }]) // First content lands (running turn): engaging → active. session.handleRunning(true) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index a02ec631e8..0a588e8329 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -10,11 +10,9 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { resolvedClientTimeZone } from '../src/client/time-zone.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId -const CLIENT_TIME_ZONE = resolvedClientTimeZone() interface Bench { ctx: Context @@ -454,11 +452,7 @@ describe('create', () => { const b = bench() b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') - expect(b.api.callsOf('session.create')).toEqual([{ - cwd: '/w', - sessionId: 'fresh', - timeZone: CLIENT_TIME_ZONE, - }]) + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) b.api.onCreate = () => Promise.resolve({ rpcId: 'e' as never, result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } }, diff --git a/packages/client/runtime/tests/time-zone.spec.ts b/packages/client/runtime/tests/time-zone.spec.ts index 2bd11d46df..d96c9476c1 100644 --- a/packages/client/runtime/tests/time-zone.spec.ts +++ b/packages/client/runtime/tests/time-zone.spec.ts @@ -12,11 +12,11 @@ describe('browser time zone', () => { ) }) - it('fails loud when the runtime exposes no zone', () => { + it.each([undefined, ''])('fails loud when the runtime exposes no zone %#', (timeZone) => { const options = new Intl.DateTimeFormat().resolvedOptions() vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ ...options, - timeZone: '', + timeZone: timeZone as string, }) expect(() => resolvedClientTimeZone()).toThrow('browser time zone is unavailable') diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index c345c370bc..832a1ff71a 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -2,14 +2,12 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' -import { resolvedClientTimeZone } from '../src/client/time-zone.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId const wid = (id: string): WorkspaceId => id as WorkspaceId -const CLIENT_TIME_ZONE = resolvedClientTimeZone() function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView { return { @@ -190,10 +188,7 @@ describe('WorkspacesService', () => { // Miss: beta has only a non-blank session → host create with workspaceId. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') })) await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh') - expect(api.callsOf('session.create')).toEqual([{ - workspaceId: 'beta', - timeZone: CLIENT_TIME_ZONE, - }]) + expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }]) // Same guarantee on the create arm (draft hand-off writes the machine pre-open). expect(sessions.binding(sid('s-fresh'))).toBeDefined() @@ -201,10 +196,7 @@ describe('WorkspacesService', () => { // never reused, a fresh accounted session is created instead. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') })) await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3') - expect(api.callsOf('session.create')).toEqual([ - { workspaceId: 'beta', timeZone: CLIENT_TIME_ZONE }, - { workspaceId: 'gamma', timeZone: CLIENT_TIME_ZONE }, - ]) + expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }]) // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) @@ -419,10 +411,7 @@ describe('startInitialSelection', () => { await b.sessions.refresh() // Store notifications and the connect round trip are microtask-batched. await new Promise(resolve => setTimeout(resolve, 0)) - expect(b.api.callsOf('session.create')).toEqual([{ - workspaceId: 'recent', - timeZone: CLIENT_TIME_ZONE, - }]) + expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }]) expect(b.sessions.list.getSnapshot().current).toBe('s-new') stop() }) diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index 8e67848c71..4bd5b81b49 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/time-context/README.md -README.md: 9956918c63b49de8ec5e739bc3d9887e269930a8 -README.zh.md: 3a9bb1012fc0639d9c3f6b104cea5a64d4b187d6 +README.md: 0bdb0d463362427d6a7050c2d7d6d55f96779f9c +README.zh.md: 92eb0b3f43162279ac7e0f728e685863d75a28b6 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index c07ab9b113..0bdb0d4633 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Opt-in durable context with the current zoned time, immutable Session zone, request-bound browser zones, and elapsed time sampled during model-request preparation. Default compositions do not mount it; the opt-in Schedule Web overlay does. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md). +Opt-in durable context with the current zoned time, the browser zone attached to the open request, and elapsed time sampled during model-request preparation. Default compositions leave it disabled; the Schedule Web overlay mounts it so the model can interpret otherwise-unqualified dates and times in the user's browser zone. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md). ## Config @@ -10,31 +10,31 @@ Opt-in durable context with the current zoned time, immutable Session zone, requ - id: time-context name: '@deepseek-ai/dsh-time-context' config: - timeZone: Asia/Shanghai # optional fallback for headerless Sessions; omit for the process zone - refreshIntervalMs: 60000 # optional; omit or set to 0 for every non-empty entered request batch + timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone + refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt ``` -When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats its readings. A headerless Session instead uses the configured fallback; when `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the fallback. An explicit `timeZone` is validated at plugin load but does not override a Session-owned zone. +When the open turn contains one Host-validated browser zone, that request-local zone formats the timestamp. With missing or mixed browser provenance, `timeZone` supplies the display fallback; omitting it resolves the Node process zone once at plugin load. Node honors `TZ`, and every explicit fallback is validated through `Intl.DateTimeFormat`. -`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every non-empty entered request batch whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection. +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the Session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds elapsed since the latest injection. + +## Request-zone ownership + +The browser samples `Intl.DateTimeFormat().resolvedOptions().timeZone` for each prompt. The Host validates and canonicalizes that value before binding it to the exact durable `user-rpc` message source. Time-context examines only those sources in the open turn: one unique zone resolves the request, multiple zones are `mixed`, and none are `unavailable`. It does not read or mutate Session headers, connection state, or Schedule records. + +The resolved instruction tells the model to interpret otherwise-unqualified dates and times in that browser zone. Mixed or unavailable provenance tells the model to ask the user to clarify. This is natural-language context, not an input default at another package boundary: a tool that accepts local calendar fields still owns its explicit zone requirement. ## Timing semantics -The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a non-empty message batch, time-context derives client zones from those final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority. +The plugin prepends an `agent/pre-step` listener and delegates first. When an injection is due and the downstream decision enters, it appends one sourced `UserMessage` to the returned batch. AgentLoop records the final batch after `step/start` and before request derivation. Rejection, listener failure, or an already-aborted signal records nothing. -An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the exact snapshot marker `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`; the invariant companion and Schedule consumer both fail closed if that shape or text equality drifts. The Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history. +Each reading uses the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`. The `./invariant` companion validates that shape, re-derives the current-turn browser policy from the original `user-rpc` messages, and checks the timestamp zone and elapsed baseline. -Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path. +Positive-interval scheduling scans raw durable Session events for the latest plugin-attributed message, including a reading shadowed by compaction. It therefore survives resume without a process-local cache. A positive interval can intentionally let a later request reuse existing history without a fresh reading; the Schedule Web overlay omits the interval. -Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. +Step 1 measures from the latest preceding durable user, assistant, or tool-result message. The prompt proposed for that step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Missing baselines report `unavailable`, and backward wall-clock movement clamps elapsed time to zero. -Step 1 measures from the latest durable model-visible message before the current proposal; the prompt entering that same step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. - -A time reading records an entered request step, not a completed or successfully transmitted request. A later request-preparation failure can therefore leave the reading in history, while a failure before `step/start` cannot. - -The separately published `./invariant` companion checks the simple plugin source, open turn and step, elapsed baseline, and durable event time. It also re-derives Session and client zones from the Session header and current turn's original user-rpc messages, so duplicated source authority or mismatched rendered policy fails. The rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading. - -The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while an empty continuation or interval suppression can let a request reuse existing history without adding one. +A reading records an entered step, not a completed or transmitted request. A later preparation failure can leave it in history. The message remains in derived conversation history until compaction shadows it; `request/header` contains no time-context state, and request reconstruction uses the complete durable surface prefix after each `step/start`. ## Model Experience @@ -42,14 +42,13 @@ The time reading stays in derived conversation history until a later compaction #### What the model sees -On each non-empty entered batch that injects, one source-tagged context message contains the four lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. The Session line reports the immutable Session zone or `unavailable`, and the client line reports one resolved zone, a sorted mixed set, or `missing`. An empty continuation or positive interval can let an entered step reuse prior history without a new reading. +Each injected message contains three lines. `` is an ISO-shaped timestamp with numeric offset and IANA zone; durations use compact whole-second units. ##### First step ```markdown Time sampled while preparing turn , step 1: -Session time zone: . -Client time zone for this request: . +Browser time zone for this request: . Elapsed since the preceding model-visible message: . ``` @@ -57,14 +56,13 @@ Elapsed since the preceding model-visible message: . ```markdown Time sampled while preparing turn , step : -Session time zone: . -Client time zone for this request: . +Browser time zone for this request: . Elapsed since the preceding step context: . ``` #### Token effect -Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every non-empty entered request batch. +Each reading accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one at every eligible preparation attempt. #### KV Cache effect @@ -72,8 +70,8 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work +- **Prompt provenance only** — browser-zone context guides natural-language interpretation but does not silently supply another tool's required zone field. +- **Mixed turns ask** — if one open turn contains prompts from different browser zones, the model is told to clarify rather than guess which one owns an unqualified time. +- **Fallback is not user authority** — the configured or process zone formats the clock when browser provenance is missing or mixed, but the model-facing policy still says to clarify. - **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds. -- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp. -- **Headerless fallback zone** — a Session without `SessionHeader.timeZone` renders through the configured or process fallback but reports its Session zone as `unavailable`; consumers that require unambiguous local-time interpretation must request an explicit zone. -- **Immutable Session zone** — a Session zone does not change when another browser resumes it. The request-bound browser sources expose disagreement instead of silently changing the displayed default. -- **History cost between compactions** — omission or `0` retains one reading for every non-empty entered request batch, including batches whose later request preparation fails; empty continuations reuse prior history, while a positive interval reduces but does not eliminate this cost. +- **History cost between compactions** — omission or `0` retains one reading for every eligible attempt; a positive interval reduces but does not eliminate this cost and may leave a later request without fresh browser-zone guidance. diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index 3a9bb1012f..92eb0b3f43 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。 +可选的持久上下文,包含当前带时区时间、附加到当前开放请求的浏览器时区,以及在模型请求准备期间采样的经过时长。默认组合不启用它;Schedule Web overlay 会挂载它,使模型可以按用户的浏览器时区解释未明确限定时区的日期和时间。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。 ## 配置 @@ -10,27 +10,31 @@ - id: time-context name: '@deepseek-ai/dsh-time-context' config: - timeZone: Asia/Shanghai # optional IANA override; omit for the process zone + timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt ``` -省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。 +当当前开放轮次只包含一个经 Host 校验的浏览器时区时,使用该请求本地时区格式化时间戳。浏览器来源信息缺失或混杂时,`timeZone` 提供显示回退;省略它则会在插件加载时解析一次 Node 进程时区。Node 遵循 `TZ`,每个显式回退值都经 `Intl.DateTimeFormat` 校验。 -`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每次信号尚未中止且会进入步骤的合格步骤前处理添加上下文。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。 +`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每个信号尚未中止且将进入步骤的合格 pre-step 添加上下文。正数值只会在会话没有更早的 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。 + +## 请求时区归属 + +浏览器会为每条提示词采样 `Intl.DateTimeFormat().resolvedOptions().timeZone`。Host 校验并规范化该值,再将其绑定到确切的持久 `user-rpc` 消息来源。Time-context 只检查当前开放轮次中的这些来源:唯一一个时区可解析请求,多个时区记为 `mixed`,没有时区则记为 `unavailable`。它不会读取或修改会话标头、连接状态或 Schedule 记录。 + +解析后的指令告诉模型,把未明确限定时区的日期和时间解释为该浏览器时区。来源信息为 mixed 或 unavailable 时,模型会收到要求用户澄清的指令。这是自然语言上下文,并非另一个包边界上的输入默认值:接受本地日历字段的工具仍自行负责其显式时区要求。 ## 时序语义 -该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次中添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后、普通自动压缩(compaction)之前记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制、拒绝或失败的步骤前处理不会记录任何内容。 +该插件会前置一个 `agent/pre-step` 监听器,并先行委托下游。需要注入且下游决策进入步骤时,它会向返回批次追加一条带来源的 `UserMessage`。AgentLoop 在 `step/start` 之后、请求派生之前记录最终批次。决策被拒绝、监听器失败或信号已经中止时,不会记录任何内容。 -正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的时间读数。因此,调度可以跨轮次以及进程恢复持续生效,不需要进程本地缓存状态。它会降低追加频率与历史增长,但绝不移除现有时间读数,且每个会话独立调度。 +每个读数都使用确切的快照来源 `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`。`./invariant` 配套模块会校验该形状,根据原始 `user-rpc` 消息重新派生当前轮次的浏览器策略,并检查时间戳时区与经过时长基线。 -第 1 步从前一条模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;挂钟时间倒退时,经过时长限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次时间读数,则报告 `unavailable`。 +正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息,其中包括已被压缩(compaction)遮蔽的读数。因此,它无需进程本地缓存也能在恢复后继续生效。正数间隔可以有意让后续请求复用现有历史,而不添加新读数;Schedule Web overlay 会省略该间隔。 -时间读数记录的是一个已进入步骤的步骤前批次,不是已完成步骤或已传输请求。后续请求准备失败时,该读数可能已留在历史中;但下游步骤前监听器拒绝或失败时,该读数不会被记录。 +第 1 步从最新一条在其之前持久化的用户、助手或工具结果消息起测量。为该步骤拟议的提示词尚未追加。后续步骤从同一轮次中前一个 time-context 事件起测量。缺少基线时报告 `unavailable`,挂钟时间倒退时将经过时长限制为零。 -单独发布的 `./invariant` 配套模块会根据当前未结束的轮次、下一个步骤前位置、经过时长基线与持久事件时间检查每个归因于插件的时间读数。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使时间读数失效。 - -时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 之后使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:请求准备可能在进入步骤后失败,而间隔抑制可让请求复用现有历史,无需添加时间读数。 +读数记录的是已进入的步骤,不是已完成或已传输的请求。后续准备失败时,该读数可能留在历史中。消息会保留在派生会话历史中,直到压缩将其遮蔽;`request/header` 不含 time-context 状态,请求重建会使用每个 `step/start` 之后的完整持久表层前缀。 ## 模型体验 @@ -38,12 +42,13 @@ #### 模型看到的内容 -每次执行注入的准备尝试都会生成一条带源标记的上下文消息,包含下方两行。`` 是带数字偏移与 IANA 时区、形如 ISO 的本地时间戳;持续时间使用紧凑的整秒单位。正间隔可能使某次步骤尝试没有新时间读数。 +每条注入消息包含三行。`` 是带数字偏移和 IANA 时区、形如 ISO 的时间戳;持续时间使用紧凑的整秒单位。 ##### 第一步 ```markdown Time sampled while preparing turn , step 1: +Browser time zone for this request: . Elapsed since the preceding model-visible message: . ``` @@ -51,12 +56,13 @@ Elapsed since the preceding model-visible message: . ```markdown Time sampled while preparing turn , step : +Browser time zone for this request: . Elapsed since the preceding step context: . ``` #### Token 影响 -每条注入的两行消息都会累积,直到压缩遮蔽它。正间隔会减少添加;省略或设为 `0` 则会为每次合格准备尝试添加一条。 +每个读数都会累积,直到压缩将其遮蔽。正数间隔会减少新增读数;省略或设为 `0` 时,每次合格的准备尝试都会添加一条。 #### KV Cache 影响 @@ -64,7 +70,8 @@ Elapsed since the preceding step context: . ## 已知限制与暂缓事项 +- **仅限提示词来源信息**:浏览器时区上下文用于指导自然语言解释,但不会悄然填入另一工具所要求的时区字段。 +- **混合轮次会询问**:如果同一个开放轮次包含来自不同浏览器时区的提示词,模型会收到要求澄清的指令,而不会猜测哪个时区拥有未限定的时间。 +- **回退值不代表用户权威**:浏览器来源信息缺失或混杂时,配置或进程时区用于格式化时钟,但面向模型的策略仍要求澄清。 - **整秒显示**:时间戳与持续时间省略亚秒精度,尽管持久事件时间保留毫秒。 -- **会话事件基线**:经过时长从持久追加时间戳起计算,而非客户端传输的原始发送时间戳。 -- **进程本地默认时区**:省略设置时,使用插件加载时捕获的 Node 进程 `TZ`、宿主或容器时区,而非远程用户的时区;两者不同时,请配置显式 IANA 时区。 -- **压缩之间的历史成本**:省略设置或设为 `0` 会为每次合格准备尝试保留一条时间读数,包括后续取消或失败的尝试;正间隔可以降低但无法消除该成本。 +- **压缩之间的历史成本**:省略或设为 `0` 时,每次合格尝试都会保留一条读数;正数间隔可以降低但无法消除该成本,也可能使后续请求缺少新鲜的浏览器时区指导。 diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 955f3f41e0..3f11a6b297 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -11,14 +11,11 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' import { - deriveClientTimeZoneContext, - renderTimeZoneContext, + deriveBrowserTimeZoneContext, + renderBrowserTimeZoneContext, } from './request-zone.ts' import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' -export type { ClientTimeZoneContext } from './request-zone.ts' -export { deriveClientTimeZoneContext } from './request-zone.ts' - /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' @@ -27,7 +24,7 @@ export const inject = ['agents'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { - /** Fallback display zone for headerless Sessions. Omit to use the process zone. */ + /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */ timeZone?: string /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */ refreshIntervalMs?: number @@ -56,7 +53,7 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } -/** Find the latest model-visible event before the current proposal. */ +/** Find the latest model-visible event, excluding this plugin's pending append. */ function precedingMessageTime(agent: Agent): number | undefined { for (const event of [...agent.session.events].reverse()) { switch (event.type) { @@ -97,33 +94,32 @@ function latestInjectionTime(agent: Agent): number | undefined { return undefined } -/** Collect already-entered and proposed messages belonging to one open turn. */ +/** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const start = agent.session.events.findLastIndex( event => event.type === 'turn/start' && event.data.turn === turn, ) const entered = start < 0 ? [] - : agent.session.events.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : []) + : agent.session.events.slice(start + 1) + .flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...entered, ...proposed] } -/** Render one durable time reading. */ function renderText( now: number, turn: number, step: number, previous: number | undefined, formatter: Intl.DateTimeFormat, - displayTimeZone: string, - sessionTimeZone: string | undefined, + timeZone: string, messages: readonly UserMessage[], ): string { const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous) const baseline = step === 1 ? 'model-visible message' : 'step context' - const client = deriveClientTimeZoneContext(messages) - return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, displayTimeZone)}\n` - + `${renderTimeZoneContext(sessionTimeZone, client)}\n` + const browserContext = renderBrowserTimeZoneContext(deriveBrowserTimeZoneContext(messages)) + return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n` + + `${browserContext}\n` + `Elapsed since the preceding ${baseline}: ${elapsed}.` } @@ -141,12 +137,11 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { /** * Register a prepended pre-step listener for the lifetime of `ctx`. - * @param ctx - Plugin context; the listener is disposed with it. - * @param config - Time zone and durable refresh scheduling configuration. - * @returns A disposer that prevents an in-flight listener from contributing. - * @throws When the refresh interval or configured/process time zone is invalid. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - time zone and durable refresh scheduling configuration. + * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved. */ -export function apply(ctx: Context, config: Config): () => void { +export function apply(ctx: Context, config: Config): void { const timeZone = config.timeZone const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) @@ -161,66 +156,22 @@ export function apply(ctx: Context, config: Config): () => void { } const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone const formatters = new Map([[fallbackTimeZone, fallbackFormatter]]) - let disposed = false - /** Resolve one Session-owned formatter without making the process zone authoritative. */ + /** Resolve and cache one request-local timestamp formatter. */ const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => { const existing = formatters.get(selectedTimeZone) if (existing !== undefined) return existing - let created: Intl.DateTimeFormat - try { - created = createTimestampFormatter(selectedTimeZone) - } catch (error: unknown) { - throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error }) - } + const created = createTimestampFormatter(selectedTimeZone) formatters.set(selectedTimeZone, created) return created } - /** Build one current reading after downstream pre-step transforms settle. */ - const readingFor = ( - agent: Agent, - turn: number, - step: number, - messages: readonly UserMessage[], - ): UserMessage => { - const now = Date.now() - const previous = step === 1 - ? precedingMessageTime(agent) - : precedingStepContextTime(agent, turn) - const sessionTimeZone = agent.session.header.timeZone - const displayTimeZone = sessionTimeZone ?? fallbackTimeZone - const formatter = sessionTimeZone === undefined - ? fallbackFormatter - : formatterFor(sessionTimeZone) - const text = renderText( - now, - turn, - step, - previous, - formatter, - displayTimeZone, - sessionTimeZone, - requestMessages(agent, turn, messages), - ) - return createUserMessage({ - content: [{ type: 'text', text }], - source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, - }) - } - ctx.on('agent/pre-step', async ( { agent, turn, step, signal }, next, ): Promise => { - const wasDisposed = (): boolean => disposed - const wasAborted = (): boolean => signal.aborted - if (wasDisposed()) return next() const decision = await next() - if (wasDisposed() || wasAborted() || decision.kind === 'reject' - || decision.messages.length === 0) { - return decision - } + if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) { const lastInjection = latestInjectionTime(agent) @@ -228,16 +179,30 @@ export function apply(ctx: Context, config: Config): () => void { && now >= lastInjection && now - lastInjection < refreshIntervalMs) return decision } + const previous = step === 1 + ? precedingMessageTime(agent) + : precedingStepContextTime(agent, turn) + const messages = requestMessages(agent, turn, decision.messages) + const browser = deriveBrowserTimeZoneContext(messages) + const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone + const text = renderText( + now, + turn, + step, + previous, + formatterFor(selectedTimeZone), + selectedTimeZone, + messages, + ) return { kind: 'enter', messages: [ ...decision.messages, - readingFor(agent, turn, step, decision.messages), + createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, + }), ], } }, { prepend: true }) - - return () => { - disposed = true - } } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index be42a0cbe1..247289d9eb 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -3,7 +3,10 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts' +import { + deriveBrowserTimeZoneContext, + renderBrowserTimeZoneContext, +} from './request-zone.ts' import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' @@ -11,8 +14,7 @@ const SOURCE_NAME = 'time-context' const READING = new RegExp( '^Time sampled while preparing turn (\\d+), step (\\d+): ' + '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n' - + 'Session time zone: ([^.]+)\\.\\n' - + 'Client time zone for this request: (.+)\\.\\n' + + '(Browser time zone for this request: .+)\\n' + 'Elapsed since the preceding (model-visible message|step context): ' + '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$', ) @@ -22,7 +24,7 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Derive the open step owned by a time-context reading. */ +/** Derive the open step boundary at which a time-context reading may append. */ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { let openTurn: number | undefined let openStep: number | undefined @@ -68,12 +70,12 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa /** Collect the entered user messages belonging to one open turn. */ function requestMessages(history: readonly SessionEvent[], turn: number) { const start = history.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn) - return history.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : []) + return history.slice(start + 1) + .flatMap(event => event.type === 'user/message' ? [event.data] : []) } /** Validate one plugin-attributed time reading against its session position and timestamp. */ function validateReading( - session: Session, history: readonly SessionEvent[], event: SessionEvent<'user/message'>, fail: InvariantFailure, @@ -121,15 +123,13 @@ function validateReading( || section.text !== blockText) { fail('time-context source must carry only the exact snapshot text, not request authority') } - const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.` - const expectedAuthority = renderTimeZoneContext( - session.header.timeZone, - deriveClientTimeZoneContext(requestMessages(history, turn)), - ) - if (renderedAuthority !== expectedAuthority) { - fail('time-context text does not match the Session and current request zones') + const renderedBrowserContext = match[4] + const browserContext = deriveBrowserTimeZoneContext(requestMessages(history, turn)) + const expectedBrowserContext = renderBrowserTimeZoneContext(browserContext) + if (renderedBrowserContext !== expectedBrowserContext) { + fail('time-context browser-zone text does not match current-turn user messages') } - const baseline = match[6] + const baseline = match[5] if ((step === 1) !== (baseline === 'model-visible message')) { fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`) } @@ -141,20 +141,19 @@ function validateReading( || event.time < renderedTime) { fail('time-context rendered timestamp must parse and not postdate its durable event') } - const sessionTimeZone = session.header.timeZone - if (sessionTimeZone !== undefined) { + if (browserContext.kind === 'resolved') { let expectedTimestamp: string try { expectedTimestamp = formatTimestamp( renderedTime, - createTimestampFormatter(sessionTimeZone), - sessionTimeZone, + createTimestampFormatter(browserContext.timeZone), + browserContext.timeZone, ) } catch (error: unknown) { - fail(`time-context Session time zone cannot format its durable timestamp: ${String(error)}`) + fail(`time-context browser zone cannot format its durable timestamp: ${String(error)}`) } if (rendered !== expectedTimestamp) { - fail('time-context rendered timestamp does not match the Session time zone') + fail('time-context rendered timestamp does not match the unique browser zone') } } } @@ -166,7 +165,7 @@ function validateSession(session: Session, fail: InvariantFailure): void { if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue - validateReading(session, session.events.slice(0, index), event, fail) + validateReading(session.events.slice(0, index), event, fail) } } @@ -180,7 +179,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(session, session.events, event, fail) + validateReading(session.events, event, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index de65528fd1..4a3db1df39 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -1,15 +1,16 @@ -/** Request-zone derivation shared by time-context rendering and Schedule tools. */ +/** Browser-zone derivation and model-facing policy text for one open request turn. */ +import { assertNever } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' -/** Client-zone facts derived from the user-rpc messages in one open turn. */ -export type ClientTimeZoneContext = +/** Browser-zone facts derived from user-rpc messages in one open turn. */ +export type BrowserTimeZoneContext = | { readonly kind: 'resolved'; readonly timeZone: string } - | { readonly kind: 'mixed'; readonly timeZones: string[] } + | { readonly kind: 'mixed'; readonly timeZones: readonly string[] } | { readonly kind: 'missing' } -/** Read the Host-validated client zone from one ordinary user-rpc message. */ -function clientTimeZone(message: UserMessage): string | undefined { +/** Read a Host-validated browser zone from one ordinary user-rpc message. */ +function browserTimeZone(message: UserMessage): string | undefined { const source = message.source return source.kind === 'user' && 'rpcId' in source @@ -21,13 +22,15 @@ function clientTimeZone(message: UserMessage): string | undefined { } /** - * Derive the unique, mixed, or missing client zone from entered request input. - * @param messages - User messages belonging to the current open turn. - * @returns A sorted, duplicate-free request-zone context. + * Derive the unique, mixed, or missing browser zone for one open turn. + * @param messages - Entered and proposed user messages belonging to the turn. + * @returns Sorted, duplicate-free browser-zone facts. */ -export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): ClientTimeZoneContext { +export function deriveBrowserTimeZoneContext( + messages: readonly UserMessage[], +): BrowserTimeZoneContext { const timeZones = [...new Set(messages.flatMap((message) => { - const timeZone = clientTimeZone(message) + const timeZone = browserTimeZone(message) return timeZone === undefined ? [] : [timeZone] }))].sort() const [timeZone, ...remaining] = timeZones @@ -37,20 +40,23 @@ export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): C } /** - * Render Session and request-zone facts for the model-visible time reading. - * @param sessionTimeZone - Immutable Session zone, or `undefined` for legacy Sessions. - * @param client - Client zones derived from the current open turn. - * @returns The two policy lines appended to a time-context reading. + * Render the model instruction for one browser-zone context. + * @param context - Browser-zone facts for the open turn. + * @returns One durable policy line. */ -export function renderTimeZoneContext( - sessionTimeZone: string | undefined, - client: ClientTimeZoneContext, -): string { - const session = sessionTimeZone ?? 'unavailable' - const request = client.kind === 'resolved' - ? client.timeZone - : client.kind === 'mixed' - ? `mixed ${JSON.stringify(client.timeZones)}` - : 'missing' - return `Session time zone: ${session}.\nClient time zone for this request: ${request}.` +export function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string { + switch (context.kind) { + case 'resolved': + return `Browser time zone for this request: ${context.timeZone}. ` + + 'Interpret otherwise-unqualified dates and times in this zone.' + case 'mixed': + return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. ` + + 'Ask the user to clarify otherwise-unqualified dates and times.' + case 'missing': + return 'Browser time zone for this request: unavailable. ' + + 'Ask the user to clarify otherwise-unqualified dates and times.' + /* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */ + default: + return assertNever(context, 'BrowserTimeZoneContext') + } } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 9affaf780c..6798b800dc 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -45,16 +45,14 @@ function reading( step = '1', baseline = 'model-visible message', timestamp = '2026-07-14T00:00:00+00:00[UTC]', - sessionTimeZone = 'unavailable', - clientTimeZone = 'missing', + browser = 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.', ): string { return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n` - + `Session time zone: ${sessionTimeZone}.\n` - + `Client time zone for this request: ${clientTimeZone}.\n` + + `${browser}\n` + `Elapsed since the preceding ${baseline}: unavailable.` } -function preparing(turn: number, step: number): Session { +function preparing(turn: number, step: number, clientTimeZone?: string): Session { const session = Session.create(SessionId(`time-invariant-${turn}-${step}`)) for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) { session.append('turn/start', { turn: priorTurn }) @@ -63,7 +61,9 @@ function preparing(turn: number, step: number): Session { session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], - source: { kind: 'user' }, + source: clientTimeZone === undefined + ? { kind: 'user' } + : { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never, }), { surfaceOp: 'append' }) for (let priorStep = 1; priorStep < step; priorStep += 1) { session.append('step/start', { turn, step: priorStep }) @@ -89,8 +89,7 @@ describe('time-context invariants', () => { it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { const ctx = await setup() const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n' - + 'Session time zone: unavailable.\n' - + 'Client time zone for this request: missing.\n' + + 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\n' + 'Elapsed since the preceding step context: 4m 2s.' expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow() }) @@ -102,231 +101,47 @@ describe('time-context invariants', () => { }).not.toThrow() }) - it('rejects a reading appended after request execution starts', async () => { + it('requires browser-zone policy and timestamp to match current-turn request provenance', async () => { const ctx = await setup() - const session = preparing(1, 1) - session.append('request/header', { - header: { config: { provider: 'mock', model: 'mock' } }, - reason: 'initial', - }) + const policy = 'Browser time zone for this request: Asia/Shanghai. ' + + 'Interpret otherwise-unqualified dates and times in this zone.' expect(() => { - ctx.emit('session/event', session, event(reading())) - }).toThrow(/must precede request\/header/) - }) - - it('derives Session and client zones from their original durable owners', async () => { - const ctx = await setup() - const id = SessionId('time-invariant-zones') - const session = Session.create(id, [], { - version: 0, - id, - createdAt: SECOND, - timeZone: 'Asia/Shanghai', - }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'travel request' }], - source: { kind: 'user', rpcId: 'travel-request', clientTimeZone: 'America/New_York' } as never, - }), { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) - - expect(() => { - ctx.emit('session/event', session, event(reading( + ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading( '1', '1', 'model-visible message', '2026-07-14T08:00:00+08:00[Asia/Shanghai]', - 'Asia/Shanghai', - 'America/New_York', - ))) + policy, + ), SECOND + 456)) }).not.toThrow() expect(() => { - ctx.emit('session/event', session, event(reading( - '1', - '1', - 'model-visible message', - '2026-07-14T08:00:00+08:00[Asia/Shanghai]', - 'Asia/Shanghai', - 'Asia/Shanghai', - ))) - }).toThrow(/does not match the Session and current request zones/) + ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading())) + }).toThrow(/browser-zone text/) expect(() => { - ctx.emit('session/event', session, event(reading( + ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading( '1', '1', 'model-visible message', '2026-07-14T00:00:00+00:00[UTC]', - 'Asia/Shanghai', - 'America/New_York', + policy, ))) - }).toThrow(/rendered timestamp does not match the Session time zone/) + }).toThrow(/rendered timestamp does not match the unique browser zone/) }) - it('rejects a durable reading whose Session zone cannot format the timestamp', async () => { + it('rejects invalid browser provenance loaded across the durable boundary', async () => { const ctx = await setup() - const id = SessionId('time-invariant-invalid-zone') - const session = Session.create(id, [], { - version: 0, - id, - createdAt: SECOND, - timeZone: 'Invalid/Zone', - }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'invalid zone request' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) - + const timeZone = 'Not/A_Real_Zone' + const policy = `Browser time zone for this request: ${timeZone}. ` + + 'Interpret otherwise-unqualified dates and times in this zone.' expect(() => { - ctx.emit('session/event', session, event(reading( + ctx.emit('session/event', preparing(1, 1, timeZone), event(reading( '1', '1', 'model-visible message', - '2026-07-14T00:00:00+00:00[UTC]', - 'Invalid/Zone', + `2026-07-14T00:00:00+00:00[${timeZone}]`, + policy, ))) - }).toThrow(/Session time zone cannot format its durable timestamp/) - }) - - it('rejects a malformed reading seeded after companion setup', async () => { - const ctx = await setup() - const id = SessionId('time-invariant-future-seed') - const text = reading( - '1', - '1', - 'model-visible message', - '2026-07-14T00:00:00+00:00[UTC]', - 'Asia/Shanghai', - 'Asia/Shanghai', - ) - expect(() => ctx.sessions.create(id, { - meta: { timeZone: 'Asia/Shanghai' }, - seed: [ - { type: 'turn/start', seq: 0, time: SECOND, data: { turn: 1 } }, - { - type: 'user/message', - seq: 1, - time: SECOND, - surfaceOp: 'append', - data: createUserMessage({ - content: [{ type: 'text', text: 'seeded request' }], - source: { kind: 'user', rpcId: 'seeded-request', clientTimeZone: 'Asia/Shanghai' } as never, - }), - }, - { type: 'step/start', seq: 2, time: SECOND, data: { turn: 1, step: 1 } }, - { ...event(text), seq: 3, surfaceOp: 'append' }, - ], - })).toThrow(/rendered timestamp does not match the Session time zone/) - expect(ctx.sessions.get(id)).toBeUndefined() - }) - - it('rejects a time-context source that duplicates request authority', async () => { - const ctx = await setup() - const base = event(reading()) - const duplicate: SessionEvent<'user/message'> = { - ...base, - data: { - ...base.data, - source: { ...base.data.source, authority: {} } as never, - }, - } - expect(() => { - ctx.emit('session/event', preparing(1, 1), duplicate) - }).toThrow(/must carry only the exact snapshot text/) - }) - - it('rejects snapshot provenance whose section differs from the model-visible text', async () => { - const ctx = await setup() - const base = event(reading()) - const mismatched: SessionEvent<'user/message'> = { - ...base, - data: { - ...base.data, - source: { - kind: 'plugin', - plugin: 'time-context', - form: 'snapshot', - sections: [{ name: 'time-context', text: 'different' }], - }, - }, - } - expect(() => { - ctx.emit('session/event', preparing(1, 1), mismatched) - }).toThrow(/must carry only the exact snapshot text/) - }) - - it('rejects snapshot provenance whose sections are only array-like', async () => { - const ctx = await setup() - const base = event(reading()) - const arrayLike: SessionEvent<'user/message'> = { - ...base, - data: { - ...base.data, - source: { - kind: 'plugin', - plugin: 'time-context', - form: 'snapshot', - sections: { 0: { name: 'time-context', text: reading() }, length: 1 }, - } as never, - }, - } - expect(() => { - ctx.emit('session/event', preparing(1, 1), arrayLike) - }).toThrow(/must carry only the exact snapshot text/) - }) - - it.each([ - [ - 'matched non-string text', - { type: 'text', text: 7 }, - [{ name: 'time-context', text: 7 }], - /must contain exactly one text block/, - ], - [ - 'an extra text-block field', - { type: 'text', text: reading(), extra: true }, - [{ name: 'time-context', text: reading() }], - /must contain exactly one text block/, - ], - [ - 'an extra section field', - { type: 'text', text: reading() }, - [{ name: 'time-context', text: reading(), extra: true }], - /must carry only the exact snapshot text/, - ], - ] as const)( - 'rejects snapshot provenance with %s', - async (_name, block, sections, diagnostic) => { - const ctx = await setup() - const base = event(reading()) - const malformed: SessionEvent<'user/message'> = { - ...base, - data: { - ...base.data, - content: [block as never], - source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never, - }, - } - expect(() => { - ctx.emit('session/event', preparing(1, 1), malformed) - }).toThrow(diagnostic) - }, - ) - - it('rejects package-owned provenance without snapshot sections', async () => { - const ctx = await setup() - const base = event(reading()) - const unformed: SessionEvent<'user/message'> = { - ...base, - data: { - ...base.data, - source: { kind: 'plugin', plugin: 'time-context' }, - }, - } - expect(() => { - ctx.emit('session/event', preparing(1, 1), unformed) - }).toThrow(/must carry only the exact snapshot text/) + }).toThrow(/browser zone cannot format/) }) it('validates each existing reading against its preceding durable prefix', async () => { @@ -377,22 +192,23 @@ describe('time-context invariants', () => { .toThrow(/inside an open turn/) }) - it('rejects a reading before step/start', async () => { - const ctx = await setup() - const session = Session.create(SessionId('time-invariant-turn-only')) - session.append('turn/start', { turn: 1 }) - expect(() => { ctx.emit('session/event', session, event(reading())) }).toThrow(/follow step\/start/) - }) - - it('rejects a reading outside its open preparation', async () => { + it('rejects a reading outside a prompt boundary', async () => { const ctx = await setup() const ended = preparing(1, 1) ended.append('step/end', { turn: 1, step: 1 }) - expect(() => { ctx.emit('session/event', ended, event(reading())) }) - .toThrow(/follow step\/start/) + expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/follow step\/start/) + const notEntered = Session.create(SessionId('time-invariant-turn-only')) + notEntered.append('turn/start', { turn: 1 }) + expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/follow step\/start/) expect(() => { ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading())) }).toThrow(/inside an open turn/) + const requested = preparing(1, 1) + requested.append('request/header', { + header: { config: { provider: 'mock', model: 'model' } }, + reason: 'initial', + }) + expect(() => { ctx.emit('session/event', requested, event(reading())) }).toThrow(/precede request\/header/) }) it.each([ @@ -409,6 +225,7 @@ describe('time-context invariants', () => { ['ignored', SECOND, [], /exactly one text block/], ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/], ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], + [reading(), SECOND, [{ type: 'text', text: reading(), extra: true }], /exactly one text block/], ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => { const ctx = await setup() const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1 @@ -421,6 +238,55 @@ describe('time-context invariants', () => { }).toThrow(message) }) + it('requires exact snapshot provenance without copied request authority', async () => { + const ctx = await setup() + const base = event(reading()) + for (const source of [ + { kind: 'plugin', plugin: 'time-context' }, + { ...base.data.source, authority: {} }, + { + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ name: 'time-context', text: 'different' }], + }, + { + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: { 0: { name: 'time-context', text: reading() }, length: 1 }, + }, + { + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ name: 'time-context', text: reading(), extra: true }], + }, + ]) { + const malformed: SessionEvent<'user/message'> = { + ...base, + data: { ...base.data, source: source as never }, + } + expect(() => { ctx.emit('session/event', preparing(1, 1), malformed) }) + .toThrow(/must carry only the exact snapshot text/) + } + }) + + it('validates a seeded Session created after invariant registration', async () => { + const ctx = await setup() + const text = reading('1', '2', 'step context') + expect(() => { + ctx.sessions.create(SessionId('time-invariant-created-invalid'), { + seed: [ + { type: 'turn/start', seq: 0, time: SECOND, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: SECOND, data: { turn: 1, step: 1 } }, + { ...event(text), seq: 2, surfaceOp: 'append' }, + ], + }) + }).toThrow(/expected turn 1\/step 1/) + expect(ctx.sessions.get(SessionId('time-invariant-created-invalid'))).toBeUndefined() + }) + it('ignores context messages owned by another package', async () => { const ctx = await setup() const other = event('unrelated', SECOND + 456, undefined, 'other') diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts index 85bfae64db..d9f9c6c3f6 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -1,64 +1,44 @@ import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import * as timeContext from '@deepseek-ai/dsh-time-context' +import type { UserMessage } from '@deepseek-ai/dsh-llm' import { - deriveClientTimeZoneContext, -} from '@deepseek-ai/dsh-time-context' -import { renderTimeZoneContext } from '../src/request-zone.ts' + deriveBrowserTimeZoneContext, + renderBrowserTimeZoneContext, +} from '../src/request-zone.ts' -function request(clientTimeZone?: unknown) { +function browserMessage(timeZone: string): UserMessage { return createUserMessage({ - content: [{ type: 'text', text: 'request' }], - source: clientTimeZone === undefined - ? { kind: 'user' } - : { kind: 'user', rpcId: 'request-zone', clientTimeZone } as never, + content: [{ type: 'text', text: timeZone }], + source: { kind: 'user', rpcId: `rpc-${timeZone}`, clientTimeZone: timeZone } as never, }) } -describe('request-zone derivation', () => { - it('publishes derivation without exposing the internal renderer', () => { - expect(timeContext.deriveClientTimeZoneContext).toBe(deriveClientTimeZoneContext) - expect('renderTimeZoneContext' in timeContext).toBe(false) - }) - - it('derives missing, one resolved zone, and sorted unique mixed zones', () => { +describe('browser request-zone context', () => { + it('derives missing, unique, and sorted mixed zones from user-rpc messages only', () => { const plugin = createUserMessage({ - content: [], - source: { kind: 'plugin', plugin: 'fixture' }, + content: [{ type: 'text', text: 'plugin' }], + source: { kind: 'plugin', plugin: 'test' }, }) - expect(deriveClientTimeZoneContext([plugin, request(), request(1)])).toEqual({ kind: 'missing' }) - expect(deriveClientTimeZoneContext([createUserMessage({ - content: [], - source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, - })])).toEqual({ kind: 'missing' }) - expect(deriveClientTimeZoneContext([ - request('Asia/Shanghai'), - request('Asia/Shanghai'), + expect(deriveBrowserTimeZoneContext([plugin])).toEqual({ kind: 'missing' }) + expect(deriveBrowserTimeZoneContext([ + browserMessage('Asia/Shanghai'), + browserMessage('Asia/Shanghai'), ])).toEqual({ kind: 'resolved', timeZone: 'Asia/Shanghai' }) - expect(deriveClientTimeZoneContext([ - request('Asia/Shanghai'), - request('America/New_York'), + expect(deriveBrowserTimeZoneContext([ + browserMessage('Asia/Shanghai'), + browserMessage('America/New_York'), ])).toEqual({ kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'], }) }) - it('renders resolved, mixed, and unavailable policy lines', () => { - expect(renderTimeZoneContext('Asia/Shanghai', { - kind: 'resolved', - timeZone: 'Asia/Shanghai', - })).toBe( - 'Session time zone: Asia/Shanghai.\nClient time zone for this request: Asia/Shanghai.', - ) - expect(renderTimeZoneContext('UTC', { - kind: 'mixed', - timeZones: ['America/New_York', 'UTC'], - })).toBe( - 'Session time zone: UTC.\nClient time zone for this request: mixed ["America/New_York","UTC"].', - ) - expect(renderTimeZoneContext(undefined, { kind: 'missing' })).toBe( - 'Session time zone: unavailable.\nClient time zone for this request: missing.', - ) + it('renders one explicit model policy for every context', () => { + expect(renderBrowserTimeZoneContext({ kind: 'resolved', timeZone: 'Asia/Shanghai' })) + .toContain('Interpret otherwise-unqualified dates and times in this zone.') + expect(renderBrowserTimeZoneContext({ + kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'], + })).toContain('mixed ["America/New_York","Asia/Shanghai"]') + expect(renderBrowserTimeZoneContext({ kind: 'missing' })).toContain('unavailable') }) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ba946f5f78..2c5718e158 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk, UserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' @@ -53,11 +53,13 @@ function sessionAgent(session: Session, id = 'agent'): Agent { } } -function openMessageTurn(session: Session, turn: number): void { +function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void { session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], - source: { kind: 'user' }, + source: clientTimeZone === undefined + ? { kind: 'user' } + : { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never, }), { surfaceOp: 'append' }) } @@ -79,35 +81,24 @@ async function fire( turn: number, step: number, signal: AbortSignal = SIGNAL, - messages: UserMessage[] = [], ): Promise { - const fallback = messages.length === 0 - ? createUserMessage({ - content: [], - source: { kind: 'plugin', plugin: 'time-context-test-proposal' }, - }) - : undefined - const proposal = fallback === undefined ? messages : [fallback] + const proposed = createUserMessage({ + content: [{ type: 'text', text: 'request proposal' }], + source: { kind: 'plugin', plugin: 'time-context-test' }, + }) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: proposal, turn, step, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: proposal }), + { messages: [proposed], turn, step, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [proposed] }), ) if (decision.kind === 'enter') { for (const message of decision.messages) { - if (message.id === fallback?.id) continue + if (message === proposed) continue agent.session.append('user/message', message, { surfaceOp: 'append' }) } } } -function rpcMessage(text: string, clientTimeZone: string): UserMessage { - return createUserMessage({ - content: [{ type: 'text', text }], - source: { kind: 'user', rpcId: `rpc-${text}`, clientTimeZone } as never, - }) -} - function textResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, @@ -161,84 +152,22 @@ function requestText(request: GenerateOptions): string { } describe('durable step context', () => { - it('uses the immutable Session zone and the current request message zone', async () => { - const { ctx } = await mount() - const id = SessionId('session-zone') - const session = Session.create(id, [], { - version: 0, - id, - createdAt: BASE, - timeZone: 'Asia/Shanghai', - }) - session.append('turn/start', { turn: 1 }) - const agent = sessionAgent(session) - - await fire(ctx, agent, 1, 1, SIGNAL, [ - rpcMessage('local request', 'Asia/Shanghai'), - ]) - - expect(contextTexts(session)[0]).toContain( - '2026-07-14T08:00:00+08:00[Asia/Shanghai]', - ) - expect(contextTexts(session)[0]).toContain('Session time zone: Asia/Shanghai.') - expect(contextTexts(session)[0]).toContain('Client time zone for this request: Asia/Shanghai.') - const reading = session.events.at(-1) - expect(reading).toMatchObject({ - type: 'user/message', - data: { - source: { kind: 'plugin', plugin: 'time-context' }, - }, - }) - - await fire(ctx, agent, 1, 2, SIGNAL, [ - rpcMessage('same zone again', 'Asia/Shanghai'), - ]) - expect(contextTexts(session)).toHaveLength(2) - }) - - it('reports sorted mixed zones from the current request chain without changing the Session zone', async () => { - const { ctx } = await mount() - const id = SessionId('mixed-zone') - const session = Session.create(id, [], { - version: 0, - id, - createdAt: BASE, - timeZone: 'Asia/Shanghai', - }) - session.append('turn/start', { turn: 1 }) - session.append('user/message', rpcMessage('first tab', 'Asia/Shanghai'), { - surfaceOp: 'append', - }) - - await fire(ctx, sessionAgent(session), 1, 1, SIGNAL, [ - rpcMessage('second tab', 'America/New_York'), - ]) - - expect(contextTexts(session)[0]).toContain('Session time zone: Asia/Shanghai.') - expect(contextTexts(session)[0]).toContain( - 'Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].', - ) - }) - it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => { const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) const session = Session.create(SessionId('first')) - openMessageTurn(session, 1) + openMessageTurn(session, 1, 'Asia/Shanghai') vi.setSystemTime(BASE + 90_061_000) await fire(ctx, sessionAgent(session), 1, 1) expect(contextTexts(session)).toEqual([ 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' - + 'Session time zone: unavailable.\n' - + 'Client time zone for this request: missing.\n' + + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n' + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) const event = session.events.at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') - const text = event.data.content.find(block => block.type === 'text')?.text - if (text === undefined) throw new Error('missing time-context text') // The reading is a `snapshot`-form context: one named contribution whose // text is exactly what the model read, so a consumer attributes it without // re-splitting prose. @@ -248,7 +177,9 @@ describe('durable step context', () => { form: 'snapshot', sections: [{ name: 'time-context', - text, + text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' + + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n' + + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', }], }) expect(event.surfaceOp).toBe('append') @@ -281,12 +212,40 @@ describe('durable step context', () => { expect(contextTexts(session)[1]).toBe( 'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n' - + 'Session time zone: unavailable.\n' - + 'Client time zone for this request: missing.\n' + + 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\n' + 'Elapsed since the preceding step context: 1m 1s.', ) }) + it('formats in one browser zone and falls back when steering supplies mixed zones', async () => { + const { ctx } = await mount({ timeZone: 'UTC' }) + const resolved = Session.create(SessionId('browser-zone-resolved')) + openMessageTurn(resolved, 1, 'America/New_York') + await fire(ctx, sessionAgent(resolved), 1, 1) + expect(contextTexts(resolved)[0]).toContain( + '2026-07-13T20:00:00-04:00[America/New_York]\n' + + 'Browser time zone for this request: America/New_York. ' + + 'Interpret otherwise-unqualified dates and times in this zone.', + ) + + const mixed = Session.create(SessionId('browser-zone-mixed')) + openMessageTurn(mixed, 1, 'Asia/Shanghai') + mixed.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'steering from another browser' }], + source: { + kind: 'user', + rpcId: 'mixed-steer', + clientTimeZone: 'America/New_York', + } as never, + }), { surfaceOp: 'append' }) + await fire(ctx, sessionAgent(mixed), 1, 1) + expect(contextTexts(mixed)[0]).toContain( + '2026-07-14T00:00:00+00:00[UTC]\n' + + 'Browser time zone for this request: mixed ["America/New_York","Asia/Shanghai"]. ' + + 'Ask the user to clarify otherwise-unqualified dates and times.', + ) + }) + it('reports an unavailable later-step baseline at the matching turn boundary', async () => { const { ctx } = await mount() const session = Session.create(SessionId('later-step-boundary')) @@ -427,20 +386,6 @@ describe('configuration and lifecycle', () => { await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) }) - it('fails loud when a persisted Session names an invalid zone', async () => { - const { ctx } = await mount() - const id = SessionId('invalid-session-zone') - const session = Session.create(id, [], { - version: 0, - id, - createdAt: BASE, - timeZone: 'Not/A_Real_Zone', - }) - openMessageTurn(session, 1) - - await expect(fire(ctx, sessionAgent(session), 1, 1)).rejects.toThrow(/invalid Session time zone/) - }) - it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => { const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN] for (const refreshIntervalMs of invalid) { @@ -462,26 +407,13 @@ describe('configuration and lifecycle', () => { expect(contextTexts(session)).toHaveLength(1) }) - - it('lets an already-stopped direct registration delegate without contributing', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const stop = timeContext.apply(ctx, {}) - stop() - const session = Session.create(SessionId('stopped-direct-registration')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toEqual([]) - }) }) describe('real agent-loop request history', () => { it.each([ - ['throws', 0], - ['cancels', 0], - ] as const)('does not persist context when a downstream pre-step listener %s', async (mode, expectedContexts) => { + ['throws'], + ['cancels'], + ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) ctx.on('agent/pre-step', ({ agent: subject }, next) => { @@ -494,170 +426,13 @@ describe('real agent-loop request history', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })) await agent.whenIdle() - expect(contextTexts(agent.session)).toHaveLength(expectedContexts) + expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) - it('leaves steering that arrives after claim for the next step and derives fresh context', async () => { - const adapter = new ScriptedAdapter([textResponse('first'), textResponse('second')]) - const ctx = await loopHarness(adapter) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() - let blocked = true - ctx.on('system-prompt/assemble', async (_assembly, context, next) => { - if (blocked && context.agent !== undefined) { - entered.resolve(undefined) - await release.promise - } - return next() - }) - const agent = ctx.agentLoop.create(SessionId('late-steering'), { provider: 'mock', model: 'mock' }) - - agent.followup(rpcMessage('start in Shanghai', 'Asia/Shanghai')) - await entered.promise - agent.steer(rpcMessage('switch to New York', 'America/New_York')) - blocked = false - release.resolve(undefined) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(2) - expect(agent.inbox.hasPending).toBe(false) - expect(requestText(adapter.requests[0]!)).toContain('start in Shanghai') - expect(requestText(adapter.requests[0]!)).not.toContain('switch to New York') - expect(requestText(adapter.requests[0]!)).toContain('Client time zone for this request: Asia/Shanghai.') - expect(requestText(adapter.requests[1]!)).toContain('switch to New York') - expect(requestText(adapter.requests[1]!)).toContain( - 'Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].', - ) - expect(contextTexts(agent.session)).toHaveLength(2) - await ctx.fiber.dispose() - }) - - it('does not let time context create an initial step after downstream suppression', async () => { - const adapter = new ScriptedAdapter([textResponse('unused')]) - const ctx = await loopHarness(adapter) - ctx.on('agent/pre-step', async (_payload, next) => { - const decision = await next() - return decision.kind === 'reject' ? decision : { kind: 'enter', messages: [] } - }) - const agent = ctx.agentLoop.create(SessionId('suppressed-preparation'), { - provider: 'mock', - model: 'mock', - }) - - agent.followup(rpcMessage('suppress this prompt', 'Asia/Shanghai')) - await agent.whenIdle() - - expect(adapter.requests).toEqual([]) - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) - expect(contextTexts(agent.session)).toEqual([]) - expect(agent.inbox.hasPending).toBe(false) - await ctx.fiber.dispose() - }) - - it('does not revive an empty continuation after a completed step', async () => { - const adapter = new ScriptedAdapter([textResponse('done')]) - const ctx = await loopHarness(adapter) - ctx.on('agent/turn-stopping', ({ agent: subject }) => { - subject.inject(createUserMessage({ - content: [{ type: 'text', text: 'pending context' }], - source: { kind: 'plugin', plugin: 'test' }, - })) - }) - ctx.on('agent/pre-step', async ({ step }, next) => { - const decision = await next() - return step === 1 || decision.kind === 'reject' - ? decision - : { kind: 'enter', messages: [] } - }) - const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), { - provider: 'mock', - model: 'mock', - }) - - agent.followup(rpcMessage('finish once', 'Asia/Shanghai')) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) - expect(contextTexts(agent.session)).toHaveLength(1) - expect(agent.inbox.hasPending).toBe(false) - await ctx.fiber.dispose() - }) - - it('preserves post-claim steering without persisting failed-turn context', async () => { - const adapter = new ScriptedAdapter([textResponse('resumed')]) - const ctx = await loopHarness(adapter) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() - let blocked = true - ctx.on('system-prompt/assemble', async (_assembly, context, next) => { - if (blocked && context.agent !== undefined) { - entered.resolve(undefined) - await release.promise - } - return next() - }) - const agent = ctx.agentLoop.create(SessionId('cancelled-assembly'), { provider: 'mock', model: 'mock' }) - const steering = rpcMessage('preserve this steering', 'America/New_York') - - agent.followup(rpcMessage('start', 'Asia/Shanghai')) - await entered.promise - agent.steer(steering) - agent.cancel({ kind: 'user' }, { keepInbox: true }) - blocked = false - release.resolve(undefined) - await agent.whenIdle() - - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) - expect(contextTexts(agent.session)).toHaveLength(0) - expect(agent.inbox.nextStep).toEqual([steering]) - expect(agent.inbox.nextStep.some(message => - message.source.kind === 'plugin' && message.source.plugin === 'time-context')).toBe(false) - - agent.followup(rpcMessage('wake', 'America/New_York')) - await agent.whenIdle() - expect(adapter.requests).toHaveLength(1) - expect(requestText(adapter.requests[0]!)).toContain('preserve this steering') - expect(requestText(adapter.requests[0]!)).toContain('Time sampled while preparing turn 2, step 1:') - await ctx.fiber.dispose() - }) - - it('does not contribute after its disposer wins an in-flight pre-step', async () => { - const adapter = new ScriptedAdapter([textResponse('done')]) - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(AgentLoop, { agents: [] }) - const stopTimeContext = timeContext.apply(ctx, {}) - ctx.llm.registerAdapter(['mock'], adapter) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() - ctx.on('agent/pre-step', async (_payload, next) => { - entered.resolve(undefined) - await release.promise - return next() - }) - const agent = ctx.agentLoop.create(SessionId('dispose-inflight-pre-step'), { - provider: 'mock', - model: 'mock', - }) - - agent.followup(rpcMessage('continue without disposed context', 'Asia/Shanghai')) - await entered.promise - stopTimeContext() - release.resolve(undefined) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(1) - expect(requestText(adapter.requests[0]!)).not.toContain('Time sampled while preparing') - expect(contextTexts(agent.session)).toEqual([]) - expect(agent.inbox.nextStep).toEqual([]) - await ctx.fiber.dispose() - }) - - it('does not add a reading to an empty tool continuation and leaves system headers unchanged', async () => { + it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => { const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')]) const ctx = await loopHarness(adapter) ctx.tools.register(defineContentToolFixture({ @@ -678,9 +453,11 @@ describe('real agent-loop request history', () => { const contexts = agent.session.events.filter( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = agent.session.events.filter(event => event.type === 'step/start') - expect(contexts).toHaveLength(1) + expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) - expect(contexts[0]!.seq).toBeGreaterThan(starts[0]!.seq) + for (let index = 0; index < contexts.length; index += 1) { + expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq) + } expect(contexts.every(event => event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context' && event.surfaceOp === 'append')).toBe(true) @@ -691,7 +468,8 @@ describe('real agent-loop request history', () => { expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.') expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:') expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:') - expect(secondRequestText).not.toContain('Time sampled while preparing turn 1, step 2:') + expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:') + expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.') for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing') const headers = agent.session.events.filter(event => event.type === 'request/header') diff --git a/packages/context/time-context/tsdown.config.ts b/packages/context/time-context/tsdown.config.ts index 1933fbf709..c575cae3c5 100644 --- a/packages/context/time-context/tsdown.config.ts +++ b/packages/context/time-context/tsdown.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'tsdown' -/** Build both public entries separately so each inlines the shared request-zone helper. */ +/** Build both public entries separately so each inlines shared internal helpers. */ export default defineConfig([ { entry: ['lib/types/index.js'], diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 3a8a07af36..bedfc819dc 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -79,11 +79,11 @@ export interface CreateAgentOptions { /** The live agent/session identity. */ readonly sessionId: SessionId /** - * Session creation metadata: validated absolute `cwd`, caller-validated - * `timeZone`, `parentSession` fork lineage, the `seedLength` seed boundary, - * the coarse `origin` classification, and the `delegationDepth` recursion - * budget. Mirrors the `cwd`/`timeZone`/`parentSession`/`seedLength`/`origin`/ - * `delegationDepth` fields of {@link CreateSessionOptions.meta} in dsh-session (the internal-only + * Session creation metadata: validated absolute `cwd`, `parentSession` + * fork lineage, the `seedLength` seed boundary, the coarse `origin` + * classification, and the `delegationDepth` recursion budget. Mirrors the + * `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth` fields of + * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, * so the session boundary validates and snapshots it before asynchronous @@ -91,7 +91,6 @@ export interface CreateAgentOptions { */ readonly meta?: { readonly cwd?: string - readonly timeZone?: string readonly parentSession?: SessionId readonly seedLength?: number readonly origin?: 'subagent' diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 1c52bb779c..db477d9403 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -12,9 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, optional `timeZone`, `seedLength`, `origin`, and `delegationDepth`. -- `ctx.sessions.flush(session)` dispatches an awaited parallel checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; observe-only listeners return void, while a persistence listener returns literal `true` only after completing durability work. A fully successful checkpoint with at least one such acknowledgement returns `true` and emits contained `session/flushed(session, throughSeq)` with the exclusive event boundary captured at entry; no durability acknowledgement returns `false`, and unpublished, detached, or stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary. -- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -43,7 +42,7 @@ Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.c - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`timeZone`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -84,7 +83,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, timeZone?, parentSession?, seedLength?, delegationDepth? }`. The optional `timeZone` is an opaque caller-validated string: session core checks only its stored shape and preserves it verbatim through reconstruction and fork. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index f45485772a..1ce1e823a7 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -12,9 +12,8 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、可选的 `timeZone`、`seedLength`、`origin` 和 `delegationDepth`。 -- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行检查点。每个监听器都会启动,调用会等待全部结算后才报告失败;仅观察的监听器返回 void,持久化监听器只有在完成持久化工作后才返回字面量 `true`。全部成功且至少有一个此类确认时,调用返回 `true`,并发布受包含的 `session/flushed(session, throughSeq)`,其中 `throughSeq` 是入口处捕获的事件排他边界;没有持久化确认时返回 `false`,未发布、已脱离或陈旧对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。 -- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 +- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -43,7 +42,7 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`timeZone`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 @@ -84,7 +83,7 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, timeZone?, parentSession?, seedLength?, delegationDepth? }`。可选的 `timeZone` 是由调用方校验的不透明字符串:会话核心仅检查其存储形状,并在重建和 fork 过程中原样保留。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5dff3b377e..2e9bf49271 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -135,9 +135,6 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`) } } - if (record.timeZone !== undefined && typeof record.timeZone !== 'string') { - throw new Error('session header timeZone must be a string') - } if (record.parentSession !== undefined && typeof record.parentSession !== 'string') { throw new Error('session header parentSession must be a string') } @@ -451,8 +448,8 @@ export class Session { } /** - * Detached, deep-frozen creation metadata (format version, cwd, time zone, - * lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a @@ -828,8 +825,8 @@ export class SessionStore extends Service { * Create a session owned by the calling fiber: disposing that fiber stops * event notification and removes the session from the store. `options.seed` * populates the session with a copy of those events (replay/fork); - * `options.meta` attaches creation metadata (validated absolute `cwd`, opaque - * time-zone string, seed and parent lineage, and delegation depth) as the immutable + * `options.meta` attaches creation metadata (validated absolute `cwd`, seed + * and parent lineage, and delegation depth) as the immutable * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the @@ -897,7 +894,6 @@ export class SessionStore extends Service { id: sessionId, createdAt: meta?.createdAt ?? Date.now(), ...meta?.cwd === undefined ? {} : { cwd: meta.cwd }, - ...meta?.timeZone === undefined ? {} : { timeZone: meta.timeZone }, ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.origin === undefined ? {} : { origin: meta.origin }, @@ -1106,7 +1102,6 @@ export class SessionStore extends Service { seed, meta: { ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {}, - ...liveSource.header.timeZone !== undefined ? { timeZone: liveSource.header.timeZone } : {}, parentSession: liveSource.id, seedLength: seed.length, }, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 469aafa0d1..1c9a622643 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -51,11 +51,6 @@ export interface SessionHeader { readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string - /** - * Optional caller-validated time-zone identifier captured at creation. - * Session core preserves the exact string without interpreting or canonicalizing it. - */ - readonly timeZone?: string /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** @@ -90,8 +85,6 @@ export interface CreateSessionOptions { */ readonly meta?: { readonly cwd?: string - /** Caller-validated time-zone identifier to preserve verbatim in the header. */ - readonly timeZone?: string readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 333337cc06..0e7a0629c3 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -63,9 +63,7 @@ function inherited(session: Session): readonly SessionEvent[] { describe('SessionStore.fork', () => { it('forks an empty live session as an empty child with lineage metadata', async () => { const { ctx, sessions } = await setup() - const source = ctx.sessions.create(SessionId('empty-parent'), { - meta: { cwd: '/workspace', timeZone: 'Asia/Shanghai' }, - }) + const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) const child = sessions.fork(source, undefined, SessionId('empty-child')) @@ -73,21 +71,11 @@ describe('SessionStore.fork', () => { expect(child.header).toMatchObject({ id: SessionId('empty-child'), cwd: '/workspace', - timeZone: 'Asia/Shanghai', parentSession: SessionId('empty-parent'), seedLength: 0, }) }) - it('keeps a headerless fork headerless', async () => { - const { ctx, sessions } = await setup() - const source = ctx.sessions.create(SessionId('headerless-parent'), { meta: { cwd: '/workspace' } }) - - const child = sessions.fork(source, undefined, SessionId('headerless-child')) - - expect(child.header.timeZone).toBeUndefined() - }) - it('forks the latest completed boundary by default into detached frozen seed events', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 1b3f817956..39f0530874 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -997,7 +997,6 @@ describe('Session', () => { id: SessionId('header-owned'), createdAt: 123, cwd: '/accepted', - timeZone: 'Caller/Canonical', parentSession: SessionId('parent'), seedLength: 2, } @@ -1010,7 +1009,6 @@ describe('Session', () => { id: 'header-owned', createdAt: 123, cwd: '/accepted', - timeZone: 'Caller/Canonical', parentSession: 'parent', seedLength: 2, }) @@ -1065,7 +1063,6 @@ describe('Session', () => { { header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ }, { header: { ...base, cwd: 1 }, error: /header cwd must be a string/ }, { header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ }, - { header: { ...base, timeZone: 1 }, error: /header timeZone must be a string/ }, { header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ }, { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, @@ -1276,17 +1273,16 @@ describe('SessionStore', () => { expect(session.header.parentSession).toBeUndefined() }) - it('attaches cwd, timeZone, and parentSession from meta to the header', async () => { + it('attaches cwd and parentSession from meta to the header', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('child'), { - meta: { cwd: '/work/project', timeZone: 'Asia/Shanghai', parentSession: SessionId('parent') }, + meta: { cwd: '/work/project', parentSession: SessionId('parent') }, }) expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'child', cwd: '/work/project', - timeZone: 'Asia/Shanghai', parentSession: 'parent', }) }) @@ -1311,7 +1307,6 @@ describe('SessionStore', () => { const cases: Array<{ meta: unknown; error: RegExp }> = [ { meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ }, { meta: { cwd: 1 }, error: /header cwd must be a string/ }, - { meta: { timeZone: 1 }, error: /header timeZone must be a string/ }, { meta: { parentSession: 1 }, error: /header parentSession must be a string/ }, { meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ }, { meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 3069ec7948..4f5d6c9d72 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 9b430fca1c2352334e6428eb37b80726a4e02c02 -README.zh.md: c36a88b44348d0054e71ea9d796291b91fadbf20 +README.md: 592e831a2e06e144844607cc7d7b71998f7fb11c +README.zh.md: f26cc471b4402c9a1d5fc5029aef4995ee1d1441 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 9b430fca1c..592e831a2e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -34,6 +34,8 @@ Session titles ride the generic projection pair like every other domain — the Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. +`session.prompt` also accepts optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it. + Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c36a88b443..f26cc471b4 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,6 +34,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 +`session.prompt` 还接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值,Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。 + 待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d0f3698328..a98f88da32 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -99,6 +99,25 @@ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) /** Product settings intentionally exposed beside model-provider namespaces. */ const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +/** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */ +function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) + .resolvedOptions().timeZone + /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined + return canonical + } catch { + // Intl rejects unsupported zone names; the RPC maps that parser rejection below. + return undefined + } +} + /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { return signal.aborted @@ -1803,12 +1822,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async prompt(request) { - const { sessionId, mode, content } = request.payload + const { sessionId, mode, content, clientTimeZone } = request.payload + const canonicalTimeZone = clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(clientTimeZone) + if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { + return err(request, { + code: 'invalid-time-zone', + message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', + details: { value: clientTimeZone }, + }) + } const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) if ('refused' in resolved) return resolved.refused const agent = resolved.agent - // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). - const source: MessageSource = { kind: 'user', rpcId: request.rpcId } + // Request identity and optional browser zone ride the exact durable user message. + const source: MessageSource = { + kind: 'user', + rpcId: request.rpcId, + ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), + } try { const message: UserMessage = createUserMessage({ content, source }) if (mode === 'steer') agent.steer(message) diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 99aeea55b8..4d667d387a 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -36,25 +36,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }), - z.object({ - code: z.literal('session-conflict'), - message: z.string(), - details: z.object({ - sessionId: z.string(), - requestedCwd: z.string(), - existingCwd: z.string().optional(), - requestedTimeZone: z.string(), - existingTimeZone: z.string().optional(), - }), - }), - z.object({ - code: z.literal('invalid-time-zone'), - message: z.string(), - details: z.object({ - field: z.union([z.literal('timeZone'), z.literal('clientTimeZone')]), - value: z.union([z.string(), z.null()]), - }), - }), + z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }), + z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }), z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index e8ef28ef9c..7c5ded6677 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -34,14 +34,8 @@ export interface RpcErrorDetailsMap { 'cancelled': {} 'session-not-found': { sessionId: SessionId } 'model-unavailable': { provider: string; model: string } - 'session-conflict': { - sessionId: SessionId - requestedCwd: string - existingCwd?: string - requestedTimeZone: string - existingTimeZone?: string - } - 'invalid-time-zone': { field: 'timeZone' | 'clientTimeZone'; value: string | null } + 'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string } + 'invalid-time-zone': { value: string } 'workspace-attach-failed': { sessionId: SessionId; workspaceId: string } 'workspace-not-found': { workspaceId: string } 'workspace-invalid-path': { path: string } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3ee9b5f950..538d80aeb4 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -95,12 +95,11 @@ export const sessionSearchValueSchema = z.object({ hasMore: z.boolean(), }) satisfies z.ZodType>> -/** session.create payload; timeZone stays schema-optional so Host omission returns `invalid-time-zone`. */ +/** session.create request payload (at most one of workspaceId / cwd). */ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), sessionId: sessionIdSchema.optional(), - timeZone: z.string().optional(), }).refine( payload => payload.workspaceId === undefined || payload.cwd === undefined, { message: 'session.create accepts workspaceId or cwd, not both' }, @@ -247,7 +246,7 @@ export const sessionSelectModelValueSchema = z.object({ /** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ export const contentBlockSchema = z.looseObject({ type: z.string() }) -/** session.prompt payload; clientTimeZone stays schema-optional so Host omission returns `invalid-time-zone`. */ +/** session.prompt request payload, including optional browser-local request provenance. */ export const sessionPromptRequestSchema = z.object({ sessionId: sessionIdSchema, mode: z.union([z.literal('queue'), z.literal('steer')]), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 01fb3f10c4..5703b3ab0d 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -20,9 +20,10 @@ declare module '@deepseek-ai/dsh-llm' { * The prompt's rpcId is passed through MessageSource into the `user/message` event * (the client uses it to reconcile the optimistically * echoed provisional message with the event stream). kind stays `'user'` — the model face - * carries no transport vocabulary; rpcId is an extra durable-JSON field passed back to the client with the event. + * carries no transport vocabulary; rpcId and the optional Host-validated browser zone are + * durable JSON fields passed back to the client with the event. */ - 'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone: string } + 'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone?: string } } } @@ -204,20 +205,12 @@ export interface SessionsApi { /** * Creates a real session and its idle agent. At most one of `workspaceId` / * `cwd` is accepted; an omitted project uses the Host cwd. A caller may - * preallocate `sessionId`: retries with the same id, cwd, and canonical time - * zone return the same session, while a different owned identity fails with - * `session-conflict`. A headerless persisted session remains compatible with - * the same cwd but never absorbs the request zone. Workspace + * preallocate `sessionId`: retries with the same id and cwd return the same + * session, while a different cwd fails with `session-conflict`. Workspace * creation attaches the session after publication; an attach failure * returns `workspace-attach-failed` with the published session id. */ - create(request: RpcRequest<{ - workspaceId?: WorkspaceId - cwd?: string - sessionId?: SessionId - /** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */ - timeZone?: string - }>): + create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>): Promise> /** @@ -296,12 +289,16 @@ export interface SessionsApi { fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): Promise> - /** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */ + /** + * Sends a message to an ordinary session Agent. Browser callers attach their current IANA zone; + * the Host validates, canonicalizes, and records it on that exact user message. Omission remains + * valid for non-browser callers. Session-backed subagents reject with `agent-busy` and use + * `subagent.prompt`. + */ prompt(request: RpcRequest<{ sessionId: SessionId mode: 'queue' | 'steer' content: ContentBlock[] - /** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */ clientTimeZone?: string }>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 2eb3554aa0..1c1ff247a1 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -31,10 +31,7 @@ const sid = (id: string): SessionId => id as SessionId let nextRpc = 1 function request

(payload: P): RpcRequest

{ - return { - rpcId: RpcId(`cold-${String(nextRpc++)}`), - payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload }, - } + return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload } } function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { @@ -471,6 +468,81 @@ describe('subagent ownership fence', () => { expect(response.result.ok).toBe(true) expect(followup).toHaveBeenCalledOnce() }) + + it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) + const followup = vi.fn() + const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + ctx.agents.register(agent) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + const alias = 'US/Pacific' + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) + .resolvedOptions().timeZone + const zonedRequest = request({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'zoned work' }], + clientTimeZone: alias, + }) + await expect(api.sessions.prompt(zonedRequest)).resolves.toMatchObject({ + result: { ok: true }, + }) + expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({ + source: { kind: 'user', rpcId: zonedRequest.rpcId, clientTimeZone: canonical }, + })) + + const utcRequest = request({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'UTC work' }], + clientTimeZone: 'UTC', + }) + await expect(api.sessions.prompt(utcRequest)).resolves.toMatchObject({ + result: { ok: true }, + }) + expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({ + source: { kind: 'user', rpcId: utcRequest.rpcId, clientTimeZone: 'UTC' }, + })) + + const unzonedRequest = request({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'headless work' }], + }) + await expect(api.sessions.prompt(unzonedRequest)).resolves.toMatchObject({ + result: { ok: true }, + }) + expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({ + source: { kind: 'user', rpcId: unzonedRequest.rpcId }, + })) + + for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) { + const invalid = await api.sessions.prompt(request({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'invalid zone' }], + clientTimeZone, + })) + expect(invalid.result).toEqual({ + ok: false, + error: { + code: 'invalid-time-zone', + message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', + details: { value: clientTimeZone }, + }, + }) + } + expect(followup).toHaveBeenCalledTimes(3) + }) }) describe('degenerate composition (no persistence, no factory)', () => { @@ -513,89 +585,6 @@ describe('degenerate composition (no persistence, no factory)', () => { }) }) -describe('cold Session zone identity', () => { - it('rejects a different requested zone before resuming a persisted identity', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const sessionId = sid('session-cold-zone-conflict') - const meta = header('session-cold-zone-conflict', 1000, { timeZone: 'UTC' }) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), - locate: () => undefined, - } as never) - const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - - const response = await api.sessions.create(request({ - sessionId, - cwd: '/proj', - timeZone: 'Asia/Shanghai', - })) - - expect(response.result).toMatchObject({ - ok: false, - error: { - code: 'session-conflict', - details: { - sessionId, - existingCwd: '/proj', - existingTimeZone: 'UTC', - requestedTimeZone: 'Asia/Shanghai', - }, - }, - }) - expect(resume).not.toHaveBeenCalled() - }) - - it.each([ - ['a missing zone', undefined, null], - ['an invalid zone', 'CST', 'CST'], - ] as const)('rejects %s before resuming a cold Session', async (_case, clientTimeZone, detailValue) => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const sessionId = sid('session-cold-prompt-zone') - const meta = header('session-cold-prompt-zone', 1000, { timeZone: 'UTC' }) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), - locate: () => undefined, - } as never) - const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { - defaultTarget: () => ({ provider: 'p', model: 'm' }), - cwd: '/tmp', - workspaceRoot: '/tmp', - }) - - const promptRequest = request({ - sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'rejected before resume' }], - clientTimeZone: clientTimeZone ?? 'UTC', - }) - if (clientTimeZone === undefined) { - delete (promptRequest.payload as { clientTimeZone?: string }).clientTimeZone - } - const response = await api.sessions.prompt(promptRequest) - - expect(response.result).toMatchObject({ - ok: false, - error: { - code: 'invalid-time-zone', - details: { field: 'clientTimeZone', value: detailValue }, - }, - }) - expect(resume).not.toHaveBeenCalled() - expect(ctx.agents.get(sessionId)).toBeUndefined() - await ctx.fiber.dispose() - }) -}) - describe('sessions.prompt synchronous rejection', () => { it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => { const ctx = new Context() diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index bafe4a1a1b..bc1f0a14df 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -55,7 +55,7 @@ function liveAgent( id: string, turns: number, tail: Tail = 'none', - lineage: { parentSession?: SessionId; origin?: 'subagent'; timeZone?: string } = {}, + lineage: { parentSession?: SessionId; origin?: 'subagent' } = {}, ): Session { const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } }) for (let turn = 1; turn <= turns; turn++) { @@ -90,7 +90,7 @@ const api = (ctx: Context) => createApiProxy(ctx, { describe('sessions.fork', () => { it('cuts at the anchored completed turn and records lineage and cwd', async () => { const ctx = await composed() - const source = liveAgent(ctx, 'session-source', 2, 'none', { timeZone: 'Asia/Shanghai' }) + const source = liveAgent(ctx, 'session-source', 2) const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 })) expect(response.result.ok).toBe(true) if (!response.result.ok) return @@ -100,7 +100,6 @@ describe('sessions.fork', () => { ]) expect(child?.header.parentSession).toBe(source.id) expect(child?.header.cwd).toBe('/proj') - expect(child?.header.timeZone).toBe('Asia/Shanghai') await ctx.fiber.dispose() }) @@ -158,7 +157,6 @@ describe('sessions.fork', () => { id: sourceId, createdAt: 1, cwd: '/proj', - timeZone: 'America/New_York', parentSession: parentId, origin: 'subagent', } @@ -197,7 +195,6 @@ describe('sessions.fork', () => { expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({ parentSession: sourceId, cwd: '/proj', - timeZone: 'America/New_York', }) expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined() await ctx.fiber.dispose() diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 4f4dd1d69c..83d0fa3916 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -315,7 +315,6 @@ describe('Web session model selection', () => { // callable, so the refusal has to live here. const refused = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], - clientTimeZone: 'UTC', })) expect(refused.result).toMatchObject({ ok: false, diff --git a/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts deleted file mode 100644 index 70467734f7..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Schedule reminder views cross the Host only after persistence proves their - * dispatch prefix. Live append sends raw events; session/flushed replays the - * identical dispatch with a generic sidecar. History independently gates the - * same projection on an identity-matching stored prefix. - */ - -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' -import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule' - -interface FlushControl { - handler: () => true | Promise -} - -function reminderCreateData(id: string, prompt: string) { - return { - version: 1 as const, - operation: 'create' as const, - schedule: { - id: ScheduleId(id), - kind: 'after' as const, - prompt, - afterSeconds: 1, - scheduledAt: '2026-08-05T12:00:01.000Z', - }, - } -} - -async function harness(control?: FlushControl): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (control !== undefined) ctx.on('session/flush', () => control.handler()) - return ctx -} - -function appendReminder( - session: Session, - id: string, - prompt: string, -): { create: SessionEvent; dispatch: SessionEvent } { - const scheduleId = ScheduleId(id) - const create = session.append('schedule/change', reminderCreateData(id, prompt)) - const dispatch = session.append('schedule/change', { - version: 1, - operation: 'dispatch', - id: scheduleId, - }) - return { create, dispatch } -} - -async function collectEvents( - iterable: AsyncIterable>, - count: number, - abort: AbortController, -): Promise[]> { - const events: Extract[] = [] - for await (const envelope of iterable) { - if (envelope.payload.type !== 'session/event') continue - events.push(envelope.payload) - if (events.length >= count) abort.abort() - } - return events -} - -describe('commit-aware Schedule live views', () => { - it('takes the max of reverse flush completion and replays each dispatch once', async () => { - const first = Promise.withResolvers() - let calls = 0 - const ctx = await harness({ - handler: () => ++calls === 1 ? first.promise : true, - }) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - const abort = new AbortController() - const collected = collectEvents( - api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal), - 6, - abort, - ) - const session = ctx.sessions.create(SessionId('schedule-live')) - const firstPair = appendReminder(session, 'schedule-1', 'first') - const slow = ctx.sessions.flush(session) - const secondPair = appendReminder(session, 'schedule-2', 'second') - await expect(ctx.sessions.flush(session)).resolves.toBe(true) - first.resolve(true) - await expect(slow).resolves.toBe(true) - - const frames = await collected - const raw = frames.filter(frame => frame.view === undefined) - const presented = frames.filter(frame => frame.view?.for === 'event') - expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3]) - expect(presented.map(frame => frame.event.seq)).toEqual([1, 3]) - expect(presented[0]?.event).toBe(firstPair.dispatch) - expect(presented[1]?.event).toBe(secondPair.dispatch) - expect(presented.map(frame => frame.view)).toEqual([ - { - for: 'event', - view: { - scheduleId: 'schedule-1', prompt: 'first', - occurrenceAt: '2026-08-05T12:00:01.000Z', - }, - }, - { - for: 'event', - view: { - scheduleId: 'schedule-2', prompt: 'second', - occurrenceAt: '2026-08-05T12:00:01.000Z', - }, - }, - ]) - expect(firstPair.create.seq).toBe(0) - await ctx.fiber.dispose() - }) - - it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => { - let calls = 0 - const ctx = await harness({ - handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true, - }) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - const abort = new AbortController() - const collected = collectEvents( - api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal), - 3, - abort, - ) - const session = ctx.sessions.create(SessionId('schedule-retry')) - appendReminder(session, 'schedule-1', 'retry me') - await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable') - await expect(ctx.sessions.flush(session)).resolves.toBe(true) - - const frames = await collected - expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1) - expect(frames.at(-1)?.view).toMatchObject({ - for: 'event', - }) - await ctx.fiber.dispose() - }) -}) - -describe('Schedule history views', () => { - it('presents a resumed ancestor dispatch copied into a fork seed', async () => { - const ctx = await harness() - const scheduleId = ScheduleId('resumed-reminder') - const resumed = ctx.sessions.create(SessionId('schedule-resumed'), { - seed: [{ - type: 'schedule/change', - seq: 0, - time: 1, - data: reminderCreateData('resumed-reminder', 'after restart'), - }], - meta: { cwd: '/tmp' }, - }) - const dispatch = resumed.append('schedule/change', { - version: 1, - operation: 'dispatch', - id: scheduleId, - }) - const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork')) - ctx.provide('sessionPersistence', { - readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }), - } as never) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - - const response = await api.sessions.history({ - rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id }, - }) - if (!response.result.ok) throw new Error(response.result.error.message) - expect(response.result.value.events.find(entry => entry.event.seq === dispatch.seq)?.view).toEqual({ - for: 'event', - view: { - scheduleId, - prompt: 'after restart', - occurrenceAt: '2026-08-05T12:00:01.000Z', - }, - }) - await ctx.fiber.dispose() - }) - - it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => { - const ctx = await harness() - const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } }) - appendReminder(parent, 'parent-reminder', 'from parent') - const session = ctx.sessions.create(SessionId('schedule-attached'), { - seed: [...parent.events], - meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 }, - }) - let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({ - meta: session.header, - events: [...session.events.slice(0, 1)], - }) - ctx.provide('sessionPersistence', { - readFrom: () => readFrom(), - } as never) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - const history = async () => { - const response = await api.sessions.history({ - rpcId: RpcId('schedule-history'), payload: { sessionId: session.id }, - }) - if (!response.result.ok) throw new Error(response.result.error.message) - return response.result.value.events - } - - expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() - readFrom = () => Promise.resolve({ - meta: { ...session.header, delegationDepth: 0 }, - events: [...session.events.slice(0, 2)], - }) - expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ - for: 'event', - }) - readFrom = () => Promise.resolve({ - meta: { ...session.header, cwd: '/different', delegationDepth: 0 }, - events: [...session.events.slice(0, 2)], - }) - expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() - readFrom = () => Promise.resolve({ - meta: { ...session.header, timeZone: 'UTC', delegationDepth: 0 }, - events: [...session.events.slice(0, 2)], - }) - expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() - readFrom = () => Promise.reject(new Error('physical read unavailable')) - expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('presents every dispatch in detached persisted history', async () => { - const ctx = await harness() - let source: Session | undefined - const owner = await ctx.plugin(Object.assign((inner: Context) => { - source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } }) - }, { inject: ['sessions'] })) - if (source === undefined) throw new Error('session owner did not publish its session') - appendReminder(source, 'schedule-1', 'cold reminder') - const meta = source.header - const events = [...source.events] - await owner.dispose() - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - readFrom: () => Promise.resolve({ meta, events }), - } as never) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - const response = await api.sessions.history({ - rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id }, - }) - if (!response.result.ok) throw new Error(response.result.error.message) - expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({ - for: 'event', - }) - await ctx.fiber.dispose() - }) - - it('withholds a detached view that exists only in a logical inspection', async () => { - const ctx = await harness() - let source: Session | undefined - const owner = await ctx.plugin(Object.assign((inner: Context) => { - source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } }) - }, { inject: ['sessions'] })) - if (source === undefined) throw new Error('session owner did not publish its session') - appendReminder(source, 'schedule-logical', 'not physically committed') - const meta = source.header - const events = [...source.events] - await owner.dispose() - let physicalEvents = events.slice(0, 1) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - readFrom: () => Promise.resolve({ meta, events: physicalEvents }), - } as never) - const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) - const history = async () => { - const response = await api.sessions.history({ - rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id }, - }) - if (!response.result.ok) throw new Error(response.result.error.message) - return response.result.value.events - } - - expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined() - physicalEvents = events - expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' }) - await ctx.fiber.dispose() - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index a84ed35d56..637c2fcbe0 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -22,10 +22,7 @@ import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/help let nextRpc = 1 function request

(payload: P): RpcRequest

{ - return { - rpcId: RpcId(`workspace-${String(nextRpc++)}`), - payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload }, - } + return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload } } function expectOk(response: RpcResponse): T { @@ -362,157 +359,6 @@ describe('session creation and Workspace membership', () => { expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) }) - - it('canonicalizes the immutable Session zone and rejects identity conflicts', async () => { - const { api, ctx, workspaceRoot } = await harness() - const sessionId = SessionId('session-zone-identity') - const alias = 'US/Eastern' - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) - .resolvedOptions().timeZone - - expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: alias }))) - expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe(canonical) - - expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: canonical }))) - const conflict = await api.sessions.create(request({ - sessionId, - cwd: workspaceRoot, - timeZone: 'Asia/Shanghai', - })) - expect(conflict.result).toMatchObject({ - ok: false, - error: { - code: 'session-conflict', - details: { - sessionId, - requestedCwd: workspaceRoot, - requestedTimeZone: 'Asia/Shanghai', - existingTimeZone: canonical, - }, - }, - }) - }) - - it('keeps a live headerless Session compatible without absorbing a request zone', async () => { - const { api, ctx, workspaceRoot } = await harness() - const session = ctx.sessions.create(SessionId('session-zone-headerless'), { - meta: { cwd: workspaceRoot }, - }) - ctx.agents.register(stubAgent(session)) - - expectOk(await api.sessions.create(request({ - sessionId: session.id, - cwd: workspaceRoot, - timeZone: 'Asia/Shanghai', - }))) - expect(session.header.timeZone).toBeUndefined() - }) - - it('serializes different-zone creates so the first immutable identity wins', async () => { - const { api, ctx, workspaceRoot } = await harness() - const sessionId = SessionId('session-zone-race') - const first = api.sessions.create(request({ - sessionId, - cwd: workspaceRoot, - timeZone: 'UTC', - })) - const second = api.sessions.create(request({ - sessionId, - cwd: workspaceRoot, - timeZone: 'Asia/Shanghai', - })) - const [firstResult, secondResult] = await Promise.all([first, second]) - - expect(firstResult.result).toMatchObject({ ok: true, value: { sessionId } }) - expect(secondResult.result).toMatchObject({ - ok: false, - error: { code: 'session-conflict', details: { existingTimeZone: 'UTC' } }, - }) - expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe('UTC') - }) - - it.each([ - [undefined, null], - ['', ''], - [' UTC', ' UTC'], - ['CST', 'CST'], - ['GMT', 'GMT'], - ['+08:00', '+08:00'], - ['Not/A_Real_Zone', 'Not/A_Real_Zone'], - ] as const)('rejects invalid Session zone input %j before Agent creation', async (timeZone, value) => { - const { api, ctx } = await harness() - const invalidRequest = request({}) - Object.assign(invalidRequest.payload, { timeZone }) - const response = await api.sessions.create(invalidRequest) - - expect(response.result).toMatchObject({ - ok: false, - error: { code: 'invalid-time-zone', details: { field: 'timeZone', value } }, - }) - expect(ctx.agents.list()).toHaveLength(0) - }) - - it('binds each canonical client zone to its own queued or steering message source', async () => { - const { api, ctx } = await harness() - const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId - const agent = ctx.agents.get(sessionId) - if (agent === undefined) throw new Error('created Agent missing') - const followup = vi.spyOn(agent, 'followup') - const steer = vi.spyOn(agent, 'steer') - const alias = 'US/Eastern' - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) - .resolvedOptions().timeZone - - expectOk(await api.sessions.prompt(request({ - sessionId, - mode: 'queue', - content: [{ type: 'text', text: 'queue' }], - clientTimeZone: alias, - }))) - expectOk(await api.sessions.prompt(request({ - sessionId, - mode: 'steer', - content: [{ type: 'text', text: 'steer' }], - clientTimeZone: 'Asia/Shanghai', - }))) - - expect(followup.mock.calls[0]?.[0].source).toMatchObject({ - kind: 'user', - clientTimeZone: canonical, - }) - expect(steer.mock.calls[0]?.[0].source).toMatchObject({ - kind: 'user', - clientTimeZone: 'Asia/Shanghai', - }) - }) - - it.each([undefined, '', 'CST', 'Not/A_Real_Zone'] as const)( - 'rejects invalid prompt zone input %j before delivery', - async (clientTimeZone) => { - const { api, ctx } = await harness() - const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId - const agent = ctx.agents.get(sessionId) - if (agent === undefined) throw new Error('created Agent missing') - const followup = vi.spyOn(agent, 'followup') - - const invalidRequest = request({ - sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'rejected' }], - }) - Object.assign(invalidRequest.payload, { clientTimeZone }) - const response = await api.sessions.prompt(invalidRequest) - - expect(response.result).toMatchObject({ - ok: false, - error: { - code: 'invalid-time-zone', - details: { field: 'clientTimeZone', value: clientTimeZone ?? null }, - }, - }) - expect(followup).not.toHaveBeenCalled() - }, - ) }) describe('Host Workspace increments', () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 5d90f8e7f6..83ada22644 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -310,7 +310,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { ok: true, value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false }, }) - expect((await c.sessions.create({ timeZone: 'UTC' })).result.ok).toBe(true) + expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) const selected = await c.sessions.selectModel({ sessionId: 's' as never, @@ -330,12 +330,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' }) expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } }) - expect((await c.sessions.prompt({ - sessionId: 's' as never, - mode: 'queue', - content: [{ type: 'text', text: 'x' }], - clientTimeZone: 'UTC', - })).result.ok).toBe(true) + expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.updateQueue({ sessionId: 's' as never, itemId: 'item-1' as never, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 2e6e455356..d7dcbe4d50 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -59,7 +59,8 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') - expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b', requestedTimeZone: 'UTC' } }).code).toBe('session-conflict') + expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict') + expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed') expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') @@ -245,8 +246,17 @@ describe('sessions domain schemas', () => { }], failures: [], })).toThrow() - const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] }) + const prompt = sessionPromptRequestSchema.parse({ + sessionId: 's1', + mode: 'queue', + content: [{ type: 'text', text: 'hi' }], + clientTimeZone: 'Asia/Shanghai', + }) expect(prompt.mode).toBe('queue') + expect(prompt.clientTimeZone).toBe('Asia/Shanghai') + expect(sessionPromptRequestSchema.parse({ + sessionId: 's1', mode: 'queue', content: [], + }).clientTimeZone).toBeUndefined() expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow() expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) // The command slot appears only when the prompt dispatched a slash command. diff --git a/packages/schedule/tool-schedule/README.i18n.yaml b/packages/schedule/tool-schedule/README.i18n.yaml index c408227ae7..045334b687 100644 --- a/packages/schedule/tool-schedule/README.i18n.yaml +++ b/packages/schedule/tool-schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md -README.md: 3e0a0cea98dbe593974c5604736d155508f28044 -README.zh.md: b08bad14d50b07af2c36796335452b835fb9680a +README.md: 144a72d0af36970b7888c15b16713b2ea92c1dea +README.zh.md: 8fe59f30d6f317d188e5a6c489b889af066ad46f diff --git a/packages/schedule/tool-schedule/README.md b/packages/schedule/tool-schedule/README.md index 3e0a0cea98..144a72d0af 100644 --- a/packages/schedule/tool-schedule/README.md +++ b/packages/schedule/tool-schedule/README.md @@ -2,49 +2,47 @@ English | [中文](README.zh.md) -`dsh-tool-schedule` gives future live root agents three session-scoped tools for durable one-shot reminders. Version 1 accepts positive safe-integer `after_seconds` delays and absolute `at` targets. The session event log owns reminder state; timers, tool values, and model followups are disposable projections of that log. +`dsh-tool-schedule` gives future live root Agents three Session-scoped tools for durable one-shot reminders. Version 1 accepts positive safe-integer `after_seconds` delays and explicit absolute `at` targets. The Session event log owns reminder state; timers, tool values, and model follow-ups are disposable projections of that log. ## Composition Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx.sessionPersistence`, and the persistence listener that implements Session flushes. Static injection makes a missing persistence service a composition error. The plugin listens only to later `agent/created` events, installs on runtime roots, and registers all tools through the exact `agent.ctx`. Agents that already existed when the plugin loaded and runtime children do not receive Schedule. -Load `@deepseek-ai/dsh-time-context` before publishing a root that should resolve local `at` values without an explicit zone. The official Schedule Web overlay does so. Explicit-offset and explicit-zone values remain usable without implicit request-zone context. +Time-context is not a Schedule dependency. A composition may mount `@deepseek-ai/dsh-time-context` so the model can interpret natural language in the browser's request-local zone, as the official Schedule Web overlay does. The model must still pass an explicit offset or `time_zone` to `schedule_create`; Schedule never imports or infers from model context. Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation. ## Durable state -The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of the submitted offset, local calendar fields, or interpreting zone. Delete and one-shot dispatch carry only the id. +The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of its submitted offset, local calendar fields, or interpreting zone. Delete and one-shot dispatch carry only the id. -Replay rejects unknown versions, extra fields, reused ids, and delete or dispatch transitions against inactive records. Normal sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events. +Replay rejects unknown versions, extra fields, reused ids, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events. -`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It returns `scheduleId`, prompt, and occurrence from the dispatch's nearest preceding same-id create; the client renderer adds the fixed `session-local` label. The current fork's `seedLength` is a hard boundary for child-owned dispatches, while inherited dispatches search their persisted prefix; resumed ancestors therefore remain renderable, nested generations may reuse session-local ids, and presentation never changes live ownership. +## Absolute-time input -## Absolute-time context +The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone: string }`. The string identifies an instant through `Z` or its numeric offset. The local form always requires explicit `UTC` or a valid IANA Area/Location zone. Missing `time_zone`, offset-free strings, extra keys, normalized calendar dates, invalid offsets, and non-future targets are rejected. -The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current open turn has a time-context reading and its original user-rpc sources derive one client zone equal to the immutable Session zone. - -The Web Host validates and canonicalizes the browser zone at Session creation and on every prompt. Session creation fixes `SessionHeader.timeZone`; each prompt instead carries its own `clientTimeZone` in the user-message source, so concurrent tabs do not overwrite shared state. Schedule derives directly from those original owners rather than copying them into the time-context source. A headerless Session, a missing or mixed client-zone result, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_zone`. - -Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only the canonical UTC target, and no Schedule path reads the process time zone. +Schedule owns deterministic calendar normalization. Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only canonical UTC `scheduledAt`; no Schedule path reads the browser, Session header, model time-context, connection, or process time zone. ## Management tools -The generated [tool catalog](../../../docs/tool-catalog.md) owns the argument and output schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Their canonical values use camelCase record fields even though model input uses `after_seconds`. +The generated [tool catalog](../../../docs/tool-catalog.md) owns the argument and output schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Their canonical values use camelCase record fields even though model input uses `after_seconds` and `time_zone`. -One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Direct callers therefore cannot interleave a fold with another Schedule mutation or observe a dispatch before its own barrier. `schedule_create` requires exactly one of `after_seconds` or `at`, validates shape-only failures before entering that queue, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again; an absolute target must be strictly future. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before entering the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight. +One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. `schedule_create` requires exactly one of `after_seconds` or `at`, validates shape-only failures before entering the queue, then checkpoints, allocates a never-reused id, appends create, and checkpoints again. `schedule_list` returns active records in creation order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after preflight. -Every successful management preflight also asks the live owner to recompute. This matters after a create or delete barrier returned `persistence_uncertain`: a later list or mutation can confirm the retained batch and immediately arm or retire the now-durable record without a private persistence-retry timer. +Every successful management preflight also asks the live owner to recompute. This recovers a retained create or delete batch after a previous post-append barrier returned `persistence_uncertain`, without a Schedule-specific persistence-retry timer. -The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `timezone_confirmation_required`, `not_future`, `time_out_of_range`, `corrupt_schedule_log`, `persistence_uncertain`, and `internal_error`. Diagnostics are stable and do not expose backend exceptions. Rendered content is deterministic JSON of the canonical value; generic tool-result policy remains responsible for any model-facing spill behavior. +The closed version-1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `not_future`, `time_out_of_range`, `corrupt_schedule_log`, `persistence_uncertain`, and `internal_error`. Diagnostics are stable and do not expose backend exceptions. Rendered content is deterministic JSON of the canonical value; generic tool-result policy remains responsible for any model-facing spill behavior. ## Delivery lifecycle The live owner derives the earliest target from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue. -An overdue reminder first checkpoints persistence. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task samples one decision time, builds the complete framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves the dispatch pending for a later ordinary preflight and does not start a private retry timer. +An overdue reminder first checkpoints persistence. If a turn or another maintenance task owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task refolds, builds the fixed reminder framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch. -Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown. +The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt or Schedule-specific browser UI. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. + +Framing or synchronous follow-up failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits without deleting durable records. ## Model Experience @@ -52,7 +50,7 @@ Agent or plugin disposal cancels timers, stops new work, and awaits in-flight pr #### What the model sees -The model sees the three generated tool schemas only in a live root agent created after this plugin loads. Tool results contain the canonical JSON values described above. +The model sees the three generated tool schemas only in a live root Agent created after this plugin loads. Tool results contain the canonical JSON values described above. #### Token effect @@ -62,7 +60,7 @@ The scoped schemas add a fixed request prefix while Schedule is installed. Each The three schemas remain prefix-stable while their definitions and scope stay unchanged. Tool calls and results append to later history and preserve an already reusable prefix. -### Due reminder followup +### Due reminder follow-up #### What the model sees @@ -80,17 +78,17 @@ reminder_prompt_json: #### Token effect -Each dispatched `after` or `at` reminder adds one data-dependent user-role message. The message remains in session history and therefore contributes tokens to later requests until ordinary compaction removes or replaces that history. +Each dispatched one-shot reminder adds one data-dependent user-role message. It remains in Session history and contributes tokens until ordinary compaction removes or replaces that history. #### KV Cache effect -The reminder appends after existing history and preserves its reusable prefix. Its id, occurrence, or prompt changes only the appended suffix. +The reminder appends after existing history and preserves its reusable prefix. Its id, occurrence, and prompt affect only the appended suffix. ## Known Limitations and Deferred Work -- **Session-local delivery only** — a reminder runs on time only while its original session is live; a cold session receives no external notification and processes an overdue record only after resume. -- **Activity-driven retry** — a rejected due preflight or contained framing/enqueue failure leaves the overdue record active but starts no private retry timer; the owner retries after later Agent activity reaches idle or a successful Schedule management preflight asks it to recompute. -- **One-shot protocol only** — version 1 supports `after` and `at` but rejects `every_seconds` and `cron`; recurring rules require their own transition and budget semantics rather than hidden compatibility fields. -- **Immutable Session zone** — a new Schedule Web Session captures one default browser zone and has no zone editor. Older headerless Sessions remain `unavailable`, and a mismatched or ambiguous request must name `time_zone` explicitly. -- **Narrow crash duplicate window** — a crash after synchronous followup admission but before the dispatch checkpoint can repeat the reminder after recovery; the package does not claim model completion, user acknowledgement, or exactly-once external effects. -- **Load-order boundary** — the plugin does not scan or adopt agents that were already live when it loaded. +- **Session-local delivery only** — a reminder runs on time only while its original Session is live; a cold Session receives no external notification and processes an overdue record only after resume. +- **Activity-driven retry** — a rejected due preflight or contained framing/enqueue failure leaves the record active but starts no private retry timer; later Agent activity or a successful Schedule preflight triggers recomputation. +- **Explicit local zone** — `at` never imports browser context; callers must translate natural language into either an offset-bearing RFC 3339 string or a local object with `time_zone`. +- **One-shot protocol only** — version 1 supports `after` and `at` and rejects `every_seconds` and `cron`; recurrence needs explicit transition, catch-up, and model-budget semantics. +- **Narrow crash duplicate window** — a crash after synchronous follow-up admission but before the dispatch checkpoint can repeat the reminder; the package does not claim model completion, user acknowledgement, or exactly-once effects. +- **Load-order boundary** — the plugin does not scan or adopt Agents that were already live when it loaded. diff --git a/packages/schedule/tool-schedule/README.zh.md b/packages/schedule/tool-schedule/README.zh.md index b08bad14d5..8fe59f30d6 100644 --- a/packages/schedule/tool-schedule/README.zh.md +++ b/packages/schedule/tool-schedule/README.zh.md @@ -2,49 +2,47 @@ [English](README.md) | 中文 -`dsh-tool-schedule` 为未来创建的 live 根 agent(智能体)提供 3 个会话范围内的工具,用于管理持久的一次性提醒。版本 1 接受正的安全整数 `after_seconds` 延时与绝对 `at` 目标。会话事件日志拥有提醒状态;timer、工具值与模型 `followup` 都是该日志的可丢弃投影。 +`dsh-tool-schedule` 为未来创建的 live 根 agent(智能体)提供 3 个会话范围内的工具,用于管理持久的一次性提醒。版本 1 接受正的安全整数 `after_seconds` 延时和显式绝对时间 `at` 目标。会话事件日志拥有提醒状态;timer、工具值和模型 follow-up 都是该日志的可丢弃投影。 ## 组合 请在 `ctx.sessions`、`ctx.agents`、`ctx.tools`、`ctx.sessionPersistence`,以及实现 Session flush 的持久化监听器之后加载此函数插件。静态注入会使缺少持久化服务的组合直接失败。此插件只监听后续的 `agent/created` 事件,在运行时根 agent 上安装,并通过完全相同的 `agent.ctx` 注册所有工具。插件加载时已经存在的 agent 与运行时子 agent 不会获得 Schedule。 -若根 agent 需要在未显式指定时区时解析本地 `at` 值,请在发布该 agent 前加载 `@deepseek-ai/dsh-time-context`。官方 Schedule Web overlay 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式请求时区上下文仍可使用。 +Time-context 不是 Schedule 的依赖。组合可以挂载 `@deepseek-ai/dsh-time-context`,使模型能够按浏览器的请求本地时区解释自然语言;官方 Schedule Web overlay 正是如此。模型仍必须向 `schedule_create` 传入显式偏移量或 `time_zone`;Schedule 绝不会从模型上下文中导入或推断该值。 每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。 ## 持久状态 -此包(package)拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区。delete 与一次性 dispatch 只携带 id。 +此包拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的提示词,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区。delete 与一次性 dispatch 只携带 id。 -回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。 +回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。 -`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt 和 occurrence;client renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 id,presentation 绝不会改变 live ownership。 +## 绝对时间输入 -## 绝对时间上下文 +`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone: string }`。字符串通过 `Z` 或数值偏移量标识一个时刻。本地形式始终要求显式 `UTC` 或有效的 IANA Area/Location 时区。缺少 `time_zone`、不带偏移量的字符串、额外键、需要规范化的日历日期、无效偏移量和非未来目标都会被拒绝。 -`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前 open turn 含有 time-context 读数,并且其原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`。 - -Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。Schedule 会直接从这些原始拥有方派生,而不会把它们复制进 time-context source。如果 Session 没有 header、客户端时区结果缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`。 - -落在夏令时空档内的本地时间会被拒绝。遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC 目标,Schedule 的任何路径都不会读取进程时区。 +Schedule 负责确定性的日历规范化。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC `scheduledAt`;Schedule 的任何路径都不会读取浏览器、Session 标头、模型 time-context、连接或进程时区。 ## 管理工具 -生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`,但其规范值中的记录字段使用 camelCase。 +生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。 -一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。因此,直接调用方无法让一次 fold 与另一项 Schedule 变更交错,也无法在自身的 barrier 前观察到 dispatch。`schedule_create` 要求 `after_seconds` 与 `at` 有且只有一项;它会在进入该队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点;绝对目标必须严格位于未来。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在进入该队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight(预检)后返回 `{ id, deleted: false, code: "schedule_not_found" }`。 +一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。`schedule_create` 要求 `after_seconds` 与 `at` 有且只有一项;它会在进入队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。`schedule_list` 按创建顺序返回活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在进入队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight 后返回 `{ id, deleted: false, code: "schedule_not_found" }`。 -每次成功的管理 preflight 还会要求 live owner 重新计算。这对 create 或 delete barrier 返回 `persistence_uncertain` 的情况很重要:后续 list 或 mutation 可以确认保留的 batch,并立即 arm 或退役此时已持久化的 record,而无需私有 persistence retry timer。 +每次成功的管理 preflight 还会要求 live owner 重新计算。如果先前的 post-append barrier 返回 `persistence_uncertain`,这会恢复所保留的 create 或 delete batch,而无需 Schedule 专属的持久化重试 timer。 -版本 1 的封闭领域错误代码包括 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`timezone_confirmation_required`、`not_future`、`time_out_of_range`、`corrupt_schedule_log`、`persistence_uncertain` 和 `internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON;通用工具结果策略仍负责模型可见内容的 spill 行为。 +版本 1 的封闭领域错误代码包括 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`not_future`、`time_out_of_range`、`corrupt_schedule_log`、`persistence_uncertain` 和 `internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON;通用工具结果策略仍负责模型可见内容的 spill 行为。 ## 交付生命周期 live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。 -overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。 +overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会重新折叠、构造固定的提醒 framing、同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。 -agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。 +Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执或 Schedule 专属浏览器 UI。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。 + +framing 构造或同步 follow-up 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时,会取消 timer、停止新工作,并等待进行中的 preflight 与 idle wait,且不会删除持久记录。 ## 模型体验 @@ -62,7 +60,7 @@ agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新 3 个 schema 的定义与范围不变时,前缀保持稳定。工具调用和结果会追加到后续历史中,并保留已经可以复用的前缀。 -### 到期提醒 followup +### 到期提醒 follow-up #### 模型看到的内容 @@ -80,17 +78,17 @@ reminder_prompt_json: #### Token 影响 -每条已 dispatch 的 `after` 或 `at` 提醒会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,因此会持续为后续请求贡献 token,直到普通压缩(compaction)移除或替换这段历史。 +每条已 dispatch 的一次性提醒会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,并持续贡献 token,直到普通压缩(compaction)移除或替换这段历史。 #### KV Cache 影响 -提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 或 prompt 只会改变追加的后缀。 +提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 和提示词只会影响追加的后缀。 ## 已知限制与暂缓事项 - **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行;cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。 -- **活动驱动的重试**:到期 preflight 被拒绝或 framing/入队失败被收容后,overdue 记录仍保持活动,但不会启动私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 要求 owner 重新计算后,owner 会重试。 -- **仅支持一次性协议**:版本 1 支持 `after` 与 `at`,但拒绝 `every_seconds` 和 `cron`;周期性规则需要各自的转换与预算语义,而不是隐藏的兼容字段。 -- **Session 时区不可变**:新的 Schedule Web Session 会记录一个默认浏览器时区,且没有时区编辑器。旧有的无 header Session 仍为 `unavailable`,不匹配或有歧义的请求必须显式指定 `time_zone`。 -- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。 -- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。 +- **活动驱动的重试**:到期 preflight 被拒绝或 framing/入队失败被收容后,记录仍保持活动,但不会启动私有重试 timer;后续 Agent 活动或成功的 Schedule preflight 会触发重新计算。 +- **显式本地时区**:`at` 绝不会导入浏览器上下文;调用方必须把自然语言转换为带偏移量的 RFC 3339 字符串,或带 `time_zone` 的本地对象。 +- **仅支持一次性协议**:版本 1 支持 `after` 与 `at`,并拒绝 `every_seconds` 与 `cron`;周期性规则需要显式的状态转换、追赶和模型预算语义。 +- **存在狭窄的崩溃重复窗口**:同步 follow-up 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒重复;此包不承诺模型完成、用户确认或副作用恰好执行一次。 +- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 Agent。 diff --git a/packages/schedule/tool-schedule/package.json b/packages/schedule/tool-schedule/package.json index 283a507219..ed75632e76 100644 --- a/packages/schedule/tool-schedule/package.json +++ b/packages/schedule/tool-schedule/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-time-context": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -47,7 +46,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index f324f57420..2b45cd903b 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -12,7 +12,6 @@ import type { ScheduleChange, ScheduleId as ScheduleIdType, ScheduleRecord, - ScheduleReminderPresentation, ScheduleView, } from './types.ts' @@ -48,14 +47,13 @@ export class ScheduleLogError extends Error { } } -/** Error from a model-supplied after rule that cannot become a record. */ +/** Error from a model-supplied Schedule rule that cannot become a record. */ export class ScheduleInputError extends Error { /** Stable public Schedule input code. */ readonly code: | 'invalid_prompt' | 'invalid_rule' | 'invalid_time_zone' - | 'timezone_confirmation_required' | 'not_future' | 'time_out_of_range' @@ -70,7 +68,6 @@ export class ScheduleInputError extends Error { | 'invalid_prompt' | 'invalid_rule' | 'invalid_time_zone' - | 'timezone_confirmation_required' | 'not_future' | 'time_out_of_range', message: string, @@ -568,7 +565,6 @@ export function createAfterScheduleRecord( * @param prompt - User-authored reminder content. * @param at - Explicit-offset instant or structured local calendar value. * @param now - Single creation-time wall-clock sample in epoch milliseconds. - * @param implicitTimeZone - Confirmed Session zone for a local value that omits `time_zone`. * @returns Frozen durable absolute one-shot record. */ export function createAtScheduleRecord( @@ -576,7 +572,6 @@ export function createAtScheduleRecord( prompt: string, at: AtInput, now: number, - implicitTimeZone?: string, ): AtScheduleRecord { const normalizedPrompt = prompt.trim() if (normalizedPrompt.length === 0) { @@ -587,29 +582,22 @@ export function createAtScheduleRecord( if (typeof at === 'string') { target = parseOffsetInstant(at) } else if (isRecord(at)) { - if (!hasExactKeys(at, ['date', 'time']) && !hasExactKeys(at, ['date', 'time', 'time_zone'])) { - throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and optional time_zone.') + if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) { + throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and time_zone.') } if (typeof at['date'] !== 'string' || typeof at['time'] !== 'string') { throw new ScheduleInputError('invalid_rule', 'Local at date and time must be strings.') } const rawTimeZone = at['time_zone'] - if (rawTimeZone !== undefined && typeof rawTimeZone !== 'string') { + if (typeof rawTimeZone !== 'string') { throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.') } - const selectedTimeZone = rawTimeZone ?? implicitTimeZone - if (selectedTimeZone === undefined) { - throw new ScheduleInputError( - 'timezone_confirmation_required', - 'Local at requires an explicit time_zone for this request.', - ) - } const local: LocalAtInput = { date: at['date'], time: at['time'], - ...(rawTimeZone === undefined ? {} : { time_zone: rawTimeZone }), + time_zone: rawTimeZone, } - target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(selectedTimeZone)) + target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone)) } else { throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.') } @@ -636,64 +624,6 @@ export function scheduleView(record: ScheduleRecord, now: number): ScheduleView }) } -/** - * Derive the Web receipt for one dispatch from its owning stream segment. - * A child-owned dispatch cannot cross the current fork's `seedLength`. - * An inherited dispatch pairs with its nearest preceding same-id create, so - * resumed ancestors remain renderable and nested forks may reuse local ids. - * @param events - Complete contiguous Session log. - * @param dispatchSeq - Exact event seq to present. - * @param seedLength - Inherited fork prefix length. - * @returns The immutable receipt, or `undefined` when the selected event is not a dispatch. - */ -export function scheduleReminderPresentation( - events: readonly SessionEvent[], - dispatchSeq: number, - seedLength = 0, -): ScheduleReminderPresentation | undefined { - if (!Number.isSafeInteger(dispatchSeq) || dispatchSeq < 0) { - throw new ScheduleLogError('schedule presentation seq must be a non-negative safe integer') - } - if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) { - throw new ScheduleLogError('schedule seedLength must be within the supplied event log') - } - const event = events[dispatchSeq] - if (event === undefined || event.seq !== dispatchSeq) { - throw new ScheduleLogError('schedule presentation seq must identify the matching contiguous event') - } - if (event.type !== 'schedule/change') return undefined - const dispatch = decodeScheduleChange(event.data) - if (dispatch.operation !== 'dispatch') return undefined - - const segmentStart = dispatchSeq < seedLength ? 0 : seedLength - for (let index = dispatchSeq - 1; index >= segmentStart; index -= 1) { - const candidate = events[index] - if (candidate?.type !== 'schedule/change') continue - const change = decodeScheduleChange(candidate.data) - switch (change.operation) { - case 'create': - if (change.schedule.id !== dispatch.id) break - return Object.freeze({ - scheduleId: change.schedule.id, - prompt: change.schedule.prompt, - occurrenceAt: change.schedule.scheduledAt, - }) - case 'delete': - case 'dispatch': - if (change.id === dispatch.id) { - throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`) - } - break - /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */ - default: { - const unreachable: never = change - throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`) - } - } - } - throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`) -} - /** * Render the fixed injection-resistant model framing for a due reminder. * @param record - Due active record. diff --git a/packages/schedule/tool-schedule/src/index.ts b/packages/schedule/tool-schedule/src/index.ts index 7bda250c56..a2f1167dac 100644 --- a/packages/schedule/tool-schedule/src/index.ts +++ b/packages/schedule/tool-schedule/src/index.ts @@ -17,6 +17,7 @@ export { ScheduleLogError, allocateScheduleId, createAfterScheduleRecord, + createAtScheduleRecord, decodeScheduleChange, foldScheduleEvents, renderReminderFraming, diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index ead99c7016..46e71fe011 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -6,8 +6,6 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { deriveClientTimeZoneContext } from '@deepseek-ai/dsh-time-context' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import { @@ -87,17 +85,6 @@ const BASIC_ERROR_SCHEMAS = [ basicErrorSchema('internal_error'), ] as const -const TIME_ZONE_CONFIRMATION_SCHEMA = { - type: 'object', - additionalProperties: false, - properties: { - code: { type: 'string', required: true, const: 'timezone_confirmation_required' }, - message: { type: 'string', required: true }, - sessionTimeZone: { type: 'string', required: true }, - clientTimeZones: { type: 'array', required: true, items: { type: 'string' } }, - }, -} as const - const PERSISTENCE_ERROR_SCHEMA = { type: 'object', additionalProperties: false, @@ -111,7 +98,6 @@ const PERSISTENCE_ERROR_SCHEMA = { const ERROR_SCHEMAS = [ ...BASIC_ERROR_SCHEMAS, - TIME_ZONE_CONFIRMATION_SCHEMA, PERSISTENCE_ERROR_SCHEMA, ] as const @@ -211,103 +197,8 @@ function persistenceError( } } -/** Request-local zone evidence returned with an implicit-local confirmation failure. */ -interface AtTimeZoneContext { - readonly implicitTimeZone?: string - readonly sessionTimeZone: string - readonly clientTimeZones: string[] -} - -/** Whether one durable message is the exact time-context snapshot marker. */ -function isTimeContextReading(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const source = event.data.source - if (source.kind !== 'plugin' - || source.plugin !== 'time-context' - || Object.keys(source).length !== 4 - || source.form !== 'snapshot') return false - const blockValue: unknown = event.data.content[0] - const block = typeof blockValue === 'object' && blockValue !== null - ? blockValue as Record - : undefined - const sections: unknown = source.sections - const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined - const section = typeof sectionValue === 'object' && sectionValue !== null - ? sectionValue as Record - : undefined - return event.data.content.length === 1 - && block !== undefined - && Object.keys(block).length === 2 - && block.type === 'text' - && typeof block.text === 'string' - && Array.isArray(sections) - && sections.length === 1 - && section !== undefined - && Object.keys(section).length === 2 - && section.name === 'time-context' - && section.text === block.text -} - -/** Derive request zones only while the current open turn contains a time-context reading. */ -function currentClientTimeZoneContext(agent: Agent): ReturnType | undefined { - const events = agent.session.events - let stepStart = -1 - let turn = 0 - for (let index = events.length - 1; index >= 0; index--) { - const event = events[index] - /* v8 ignore next -- the loop bounds index to the dense Session event array. */ - if (event === undefined) continue - if (event.type === 'step/end' || event.type === 'turn/end') return undefined - if (event.type === 'step/start') { - stepStart = index - turn = event.data.turn - break - } - } - if (stepStart < 0) return undefined - const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn) - if (turnStart < 0) return undefined - const hasReading = events.slice(turnStart + 1).some(isTimeContextReading) - if (!hasReading) return undefined - const messages = events.slice(turnStart + 1) - .flatMap(event => event.type === 'user/message' ? [event.data] : []) - return deriveClientTimeZoneContext(messages) -} - -/** Resolve the only request state that may supply an omitted local time zone. */ -function atTimeZoneContext(agent: Agent): AtTimeZoneContext { - const sessionTimeZone = agent.session.header.timeZone ?? 'unavailable' - const client = currentClientTimeZoneContext(agent) - const clientTimeZones = client === undefined || client.kind === 'missing' - ? [] - : client.kind === 'resolved' - ? [client.timeZone] - : [...client.timeZones] - const implicitTimeZone = sessionTimeZone !== 'unavailable' - && client?.kind === 'resolved' - && client.timeZone === sessionTimeZone - ? sessionTimeZone - : undefined - return { - ...(implicitTimeZone === undefined ? {} : { implicitTimeZone }), - sessionTimeZone, - clientTimeZones, - } -} - /** Translate one contained input failure to the closed tool union. */ -function inputError(error: ScheduleInputError, timeZone?: AtTimeZoneContext): ScheduleToolError { - if (error.code === 'timezone_confirmation_required') { - // The domain emits this code only for the omitted-zone local-at arm, - // whose request context is computed immediately before decoding. - const requestTimeZone = timeZone as AtTimeZoneContext - return { - code: error.code, - message: error.message, - sessionTimeZone: requestTimeZone.sessionTimeZone, - clientTimeZones: requestTimeZone.clientTimeZones, - } - } +function inputError(error: ScheduleInputError): ScheduleToolError { return { code: error.code, message: error.message } } @@ -406,7 +297,7 @@ export function registerScheduleTools( description: 'Positive safe-integer delay in seconds.', }, at: { - description: 'Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone.', + description: 'Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone.', oneOf: [ { type: 'string' }, { @@ -415,7 +306,7 @@ export function registerScheduleTools( properties: { date: { type: 'string', required: true }, time: { type: 'string', required: true }, - time_zone: { type: 'string' }, + time_zone: { type: 'string', required: true }, }, }, ], @@ -434,25 +325,15 @@ export function registerScheduleTools( if (isToolError(folded)) return folded const id = allocateScheduleId(folded) let record: ScheduleRecord - let timeZone: AtTimeZoneContext | undefined try { if (args.after_seconds === undefined) { const at = args.at as AtInput - timeZone = typeof at === 'string' || at.time_zone !== undefined - ? undefined - : atTimeZoneContext(agent) - record = createAtScheduleRecord( - id, - args.prompt, - at, - Date.now(), - timeZone?.implicitTimeZone, - ) + record = createAtScheduleRecord(id, args.prompt, at, Date.now()) } else { record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now()) } } catch (error: unknown) { - return error instanceof ScheduleInputError ? inputError(error, timeZone) : internalError() + return error instanceof ScheduleInputError ? inputError(error) : internalError() } const cancelledBeforeAppend = cancellationPlaceholder(exec.signal) if (cancelledBeforeAppend !== undefined) return cancelledBeforeAppend diff --git a/packages/schedule/tool-schedule/src/types.ts b/packages/schedule/tool-schedule/src/types.ts index bd76b0345a..a9da07f664 100644 --- a/packages/schedule/tool-schedule/src/types.ts +++ b/packages/schedule/tool-schedule/src/types.ts @@ -41,8 +41,8 @@ export interface LocalAtInput { readonly date: string /** Local wall-clock time with optional one-to-three digit milliseconds. */ readonly time: string - /** Explicit IANA zone; omit only when current request authority permits the Session zone. */ - readonly time_zone?: string + /** Explicit UTC or IANA Area/Location zone. */ + readonly time_zone: string } /** Absolute selector accepted by `schedule_create`. */ @@ -116,14 +116,6 @@ export interface InvalidTimeZoneError { readonly message: string } -/** Stable error returned when a local absolute time needs an explicit zone choice. */ -export interface TimeZoneConfirmationRequiredError { - readonly code: 'timezone_confirmation_required' - readonly message: string - readonly sessionTimeZone: string - readonly clientTimeZones: string[] -} - /** Stable error returned when an absolute target is not strictly future. */ export interface NotFutureError { readonly code: 'not_future' @@ -162,7 +154,6 @@ export type ScheduleToolError = | InvalidSelectorError | InvalidRuleError | InvalidTimeZoneError - | TimeZoneConfirmationRequiredError | NotFutureError | TimeOutOfRangeError | CorruptScheduleLogError diff --git a/packages/schedule/tool-schedule/tests/domain.spec.ts b/packages/schedule/tool-schedule/tests/domain.spec.ts index 802478b6d6..f8705c21e7 100644 --- a/packages/schedule/tool-schedule/tests/domain.spec.ts +++ b/packages/schedule/tool-schedule/tests/domain.spec.ts @@ -207,19 +207,8 @@ describe('absolute record and time-zone resolution', () => { expect((error as ScheduleInputError).code).toBe('not_future') } } - try { - createAtScheduleRecord( - ScheduleId('schedule-at'), - 'x', - '9999-12-31T23:59:59.999-23:59', - now, - ) - throw new Error('expected range failure') - } catch (error: unknown) { - expect(error).toBeInstanceOf(ScheduleInputError) - expect((error as ScheduleInputError).code).toBe('time_out_of_range') - } for (const [at, sampleNow] of [ + ['9999-12-31T23:59:59.999-23:59', now], ['0001-01-01T00:00:00+23:59', Date.parse('0001-01-01T00:00:00.000Z') - 1], ['2026-08-06T01:00:00Z', Number.NaN], ] as const) { @@ -248,13 +237,10 @@ describe('absolute record and time-zone resolution', () => { } }) - it('resolves local calendar time, rejects a gap, and chooses the first overlap instant', () => { + it('resolves explicit local time, rejects a DST gap, and chooses the first overlap instant', () => { expect(createAtScheduleRecord(ScheduleId('shanghai'), 'x', { - date: '2026-08-06', time: '09:00:00', time_zone: 'Asia/Shanghai', - }, now).scheduledAt).toBe('2026-08-06T01:00:00.000Z') - expect(createAtScheduleRecord(ScheduleId('implicit'), 'x', { - date: '2026-08-06', time: '09:00:00.25', - }, now, 'Asia/Shanghai').scheduledAt).toBe('2026-08-06T01:00:00.250Z') + date: '2026-08-06', time: '09:00:00.25', time_zone: 'Asia/Shanghai', + }, now).scheduledAt).toBe('2026-08-06T01:00:00.250Z') expect(createAtScheduleRecord(ScheduleId('utc'), 'x', { date: '2026-08-06', time: '09:00:00', time_zone: 'UTC', }, now).scheduledAt).toBe('2026-08-06T09:00:00.000Z') @@ -273,6 +259,7 @@ describe('absolute record and time-zone resolution', () => { }) it.each([ + [{ date: '2026-08-06', time: '09:00:00' }], [{ date: '2026-08-06', time: '09:00:00', time_zone: 'UTC', extra: true }], [{ date: 20260806, time: '09:00:00', time_zone: 'UTC' }], [{ date: '2026-08-06', time: '09:00:00', time_zone: 8 }], @@ -289,7 +276,7 @@ describe('absolute record and time-zone resolution', () => { )).toThrow(ScheduleInputError) }) - it('rejects empty at prompts and local instants outside the four-digit range', () => { + it('rejects empty prompts and local instants outside the four-digit range', () => { expect(() => createAtScheduleRecord( ScheduleId('schedule-at'), ' ', '2026-08-06T01:00:00Z', now, )).toThrow(ScheduleInputError) @@ -304,19 +291,7 @@ describe('absolute record and time-zone resolution', () => { } }) - it('fails closed when local calendar input has no confirmed zone', () => { - try { - createAtScheduleRecord(ScheduleId('schedule-at'), 'x', { - date: '2026-08-06', time: '09:00:00', - }, now) - throw new Error('expected confirmation failure') - } catch (error: unknown) { - expect(error).toBeInstanceOf(ScheduleInputError) - expect((error as ScheduleInputError).code).toBe('timezone_confirmation_required') - } - }) - - it('derives an at view and reminder framing without persisting input interpretation', () => { + it('derives an at view and model framing without persisting input interpretation', () => { const record = createAtScheduleRecord( ScheduleId('schedule-at'), 'join meeting', @@ -329,9 +304,5 @@ describe('absolute record and time-zone resolution', () => { deliveryMode: 'session-local', }) expect(renderReminderFraming(record)).toContain('occurrence_at: 2026-08-06T01:00:00.000Z') - expect(scheduleReminderPresentation([ - scheduleEvent(atCreateData(), 0), - scheduleEvent({ version: 1, operation: 'dispatch', id: 'schedule-at' }, 1), - ], 1)).toMatchObject({ scheduleId: 'schedule-at', occurrenceAt: '2026-08-06T01:00:00.000Z' }) }) }) diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index 2e2563502d..9d4fa7f6da 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -22,10 +22,8 @@ interface ToolHarness { readonly disposeTools: () => void } -function stubAgent(ctx: Context, id: string, timeZone?: string): Agent { - const session = ctx.sessions.create(SessionId(id), { - ...(timeZone === undefined ? {} : { meta: { timeZone } }), - }) +function stubAgent(ctx: Context, id: string): Agent { + const session = ctx.sessions.create(SessionId(id)) const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) return { id: session.id, @@ -35,23 +33,23 @@ function stubAgent(ctx: Context, id: string, timeZone?: string): Agent { status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, + runMaintenance: task => task(signal), cancel(_cause: AgentCancelCause) {}, whenIdle: () => Promise.resolve(), - runMaintenance: task => task(signal), followup(_message: UserMessage) {}, steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } } -async function harness(withPersistence = true, timeZone?: string): Promise { +async function harness(withPersistence = true): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRegistry) - const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`, timeZone) + const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`) ctx.agents.register(agent) const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> } if (withPersistence) { @@ -91,25 +89,6 @@ function value(result: ToolExecutionResult): unknown { return result.value } -function appendRequestContext(agent: Agent, clientTimeZones: readonly string[]): void { - for (const [index, clientTimeZone] of clientTimeZones.entries()) { - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: `request ${index + 1}` }], - source: { kind: 'user', rpcId: `request-zone-${String(index + 1)}`, clientTimeZone } as never, - }), { surfaceOp: 'append' }) - } - const text = 'time context' - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text }], - source: { - kind: 'plugin', - plugin: 'time-context', - form: 'snapshot', - sections: [{ name: 'time-context', text }], - }, - }), { surfaceOp: 'append' }) -} - beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z')) @@ -225,7 +204,7 @@ describe('Schedule tool protocol', () => { expect(test.flushes.count).toBe(0) }) - it('creates explicit-offset and explicit-zone at records without persisting their interpretation', async () => { + it('creates offset and explicit-zone at records without persisting their input interpretation', async () => { const test = await harness() expect(value(await execute(test, 'schedule_create', { prompt: 'join meeting', at: '2026-08-06T09:00:00+08:00', @@ -255,203 +234,6 @@ describe('Schedule tool protocol', () => { expect(changes[0]?.data).not.toHaveProperty('time_zone') }) - it('fails closed when local at lacks confirmed request-zone context', async () => { - const test = await harness() - expect(value(await execute(test, 'schedule_create', { - prompt: 'ambiguous', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toEqual({ - code: 'timezone_confirmation_required', - message: 'Local at requires an explicit time_zone for this request.', - sessionTimeZone: 'unavailable', - clientTimeZones: [], - }) - expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) - - const unmarked = await harness(true, 'Asia/Shanghai') - unmarked.agent.session.append('turn/start', { turn: 1 }) - unmarked.agent.session.append('step/start', { turn: 1, step: 1 }) - unmarked.agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'request without time reading' }], - source: { kind: 'user', rpcId: 'unmarked-request', clientTimeZone: 'Asia/Shanghai' } as never, - }), { surfaceOp: 'append' }) - expect(value(await execute(unmarked, 'schedule_create', { - prompt: 'unmarked', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - code: 'timezone_confirmation_required', - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: [], - }) - }) - - it('uses the current turn request zones behind a current-step time-context marker', async () => { - const test = await harness(true, 'Asia/Shanghai') - test.agent.session.append('turn/start', { turn: 1 }) - test.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(test.agent, ['Asia/Shanghai']) - - expect(value(await execute(test, 'schedule_create', { - prompt: 'implicit local', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - kind: 'at', - scheduledAt: '2026-08-06T01:00:00.000Z', - }) - }) - - it('reports the actual Session and request zones when implicit local at needs confirmation', async () => { - const mismatch = await harness(true, 'Asia/Shanghai') - mismatch.agent.session.append('turn/start', { turn: 1 }) - mismatch.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(mismatch.agent, ['America/New_York']) - expect(value(await execute(mismatch, 'schedule_create', { - prompt: 'mismatch', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toEqual({ - code: 'timezone_confirmation_required', - message: 'Local at requires an explicit time_zone for this request.', - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: ['America/New_York'], - }) - - const mixed = await harness(true, 'Asia/Shanghai') - mixed.agent.session.append('turn/start', { turn: 1 }) - mixed.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(mixed.agent, ['Asia/Shanghai', 'America/New_York']) - expect(value(await execute(mixed, 'schedule_create', { - prompt: 'mixed', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: ['America/New_York', 'Asia/Shanghai'], - }) - - const unavailable = await harness() - unavailable.agent.session.append('turn/start', { turn: 1 }) - unavailable.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(unavailable.agent, ['America/New_York']) - expect(value(await execute(unavailable, 'schedule_create', { - prompt: 'legacy', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - sessionTimeZone: 'unavailable', - clientTimeZones: ['America/New_York'], - }) - }) - - it('reuses a same-turn snapshot marker across an empty continuation and ignores a malformed source', async () => { - const test = await harness(true, 'Asia/Shanghai') - test.agent.session.append('turn/start', { turn: 1 }) - test.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(test.agent, ['Asia/Shanghai']) - test.agent.session.append('step/end', { turn: 1, step: 1 }) - test.agent.session.append('step/start', { turn: 1, step: 2 }) - test.agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'malformed authority' }], - source: { - kind: 'plugin', - plugin: 'time-context', - authority: { turn: 1, step: 2, session: { kind: 'unavailable' }, client: { kind: 'future' } }, - } as never, - }), { surfaceOp: 'append' }) - - expect(value(await execute(test, 'schedule_create', { - prompt: 'same-turn local', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - kind: 'at', - scheduledAt: '2026-08-06T01:00:00.000Z', - }) - }) - - it('does not let an array-like snapshot marker authorize an implicit local at', async () => { - const test = await harness(true, 'Asia/Shanghai') - test.agent.session.append('turn/start', { turn: 1 }) - test.agent.session.append('step/start', { turn: 1, step: 1 }) - test.agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'request' }], - source: { kind: 'user', rpcId: 'array-like-request', clientTimeZone: 'Asia/Shanghai' } as never, - }), { surfaceOp: 'append' }) - const text = 'time context' - test.agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text }], - source: { - kind: 'plugin', - plugin: 'time-context', - form: 'snapshot', - sections: { 0: { name: 'time-context', text }, length: 1 }, - } as never, - }), { surfaceOp: 'append' }) - - expect(value(await execute(test, 'schedule_create', { - prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - code: 'timezone_confirmation_required', - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: [], - }) - }) - - it.each([ - ['a non-object text block', 7, [{ name: 'time-context', text: 'time context' }]], - ['matched non-string text', { type: 'text', text: 7 }, [{ name: 'time-context', text: 7 }]], - ['extra text-block field', { type: 'text', text: 'time context', extra: true }, [{ name: 'time-context', text: 'time context' }]], - ['extra section field', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 'time context', extra: true }]], - ] as const)( - 'does not let snapshot provenance with %s authorize an implicit local at', - async (_name, block, sections) => { - const test = await harness(true, 'Asia/Shanghai') - test.agent.session.append('turn/start', { turn: 1 }) - test.agent.session.append('step/start', { turn: 1, step: 1 }) - test.agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'request' }], - source: { kind: 'user', rpcId: 'malformed-marker-request', clientTimeZone: 'Asia/Shanghai' } as never, - }), { surfaceOp: 'append' }) - test.agent.session.append('user/message', createUserMessage({ - content: [block as never], - source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never, - }), { surfaceOp: 'append' }) - - expect(value(await execute(test, 'schedule_create', { - prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - code: 'timezone_confirmation_required', - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: [], - }) - }, - ) - - it.each(['step/end', 'turn/end'] as const)( - 'fails closed after the current %s boundary', - async (boundary) => { - const test = await harness(true, 'Asia/Shanghai') - test.agent.session.append('turn/start', { turn: 1 }) - test.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(test.agent, ['Asia/Shanghai']) - test.agent.session.append('step/end', { turn: 1, step: 1 }) - if (boundary === 'turn/end') { - test.agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - } - - expect(value(await execute(test, 'schedule_create', { - prompt: `closed ${boundary}`, - at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: [], - }) - }, - ) - - it('fails closed when an open step has no owning turn boundary', async () => { - const test = await harness(true, 'Asia/Shanghai') - test.agent.session.append('step/start', { turn: 1, step: 1 }) - appendRequestContext(test.agent, ['Asia/Shanghai']) - - expect(value(await execute(test, 'schedule_create', { - prompt: 'missing turn', at: { date: '2026-08-06', time: '09:00:00' }, - }))).toMatchObject({ - sessionTimeZone: 'Asia/Shanghai', - clientTimeZones: [], - }) - }) - it('returns stable at validation errors after persistence preflight', async () => { const test = await harness() expect(value(await execute(test, 'schedule_create', { diff --git a/packages/schedule/tool-schedule/tsconfig.json b/packages/schedule/tool-schedule/tsconfig.json index 065a80c60d..d2ac6b58d0 100644 --- a/packages/schedule/tool-schedule/tsconfig.json +++ b/packages/schedule/tool-schedule/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../core/agent" }, - { - "path": "../../context/time-context" - }, { "path": "../../core/tools" }, diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 2720deea4b..ef97c945c9 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -796,7 +796,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session', - jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, opaque\n * time-zone string, seed and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', + jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', }, { signature: 'prepare(id?: SessionId, options?: PrepareSessionOptions): Session', @@ -1909,7 +1909,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly timeZone?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -1921,7 +1921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly timeZone?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', }, { name: 'CredentialInfo', @@ -2589,7 +2589,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly timeZone?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', }, { name: 'SessionId', diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index f19166dc6f..b7fa8fc291 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, timeZone?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. An optional string `timeZone` is preserved verbatim; its absence stays absent, and a non-string stored value rejects the log. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 4595c3ad6e..cf044b937f 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, timeZone?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。可选字符串 `timeZone` 会原样保留;缺失时保持缺失,已存储值不是字符串时会拒绝日志。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 491f523b3c..96e8221c65 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -35,7 +35,6 @@ export interface HeaderLine { id: SessionId createdAt: number cwd?: string - timeZone?: string parentSession?: SessionId seedLength?: number origin?: 'subagent' @@ -54,7 +53,6 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { id: header.id, createdAt: header.createdAt, ...header.cwd !== undefined ? { cwd: header.cwd } : {}, - ...header.timeZone !== undefined ? { timeZone: header.timeZone } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, ...header.origin !== undefined ? { origin: header.origin } : {}, @@ -76,7 +74,6 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { id: line.id, createdAt: line.createdAt, ...line.cwd !== undefined ? { cwd: line.cwd } : {}, - ...line.timeZone !== undefined ? { timeZone: line.timeZone } : {}, ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, ...line.origin !== undefined ? { origin: line.origin } : {}, @@ -95,8 +92,6 @@ function isHeaderLine(value: unknown): value is HeaderLine { && Number.isSafeInteger((value as { createdAt: number }).createdAt) && (value as { createdAt: number }).createdAt >= 0 && !Object.is((value as { createdAt: number }).createdAt, -0) - && ((value as { timeZone?: unknown }).timeZone === undefined - || typeof (value as { timeZone?: unknown }).timeZone === 'string') && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number' && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index e9cc805c65..24ed94a3fb 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -786,15 +786,6 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) }) - it('round-trips an optional timeZone and rejects a non-string stored value', () => { - const zoned = meta('zoned-header', '/work', 'Asia/Shanghai') - const scanned = scanLog(Buffer.from(`${JSON.stringify(toHeaderLine(zoned))}\n`)) - - expect(scanned.meta).toEqual({ ...zoned, delegationDepth: 0 }) - const invalid = { ...toHeaderLine(zoned), timeZone: 8 } - expect(() => scanLog(Buffer.from(`${JSON.stringify(invalid)}\n`))).toThrow(/session header/) - }) - it.each([ ['missing', undefined], ['a string', '1'], diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md index 563d05373b..745c25616e 100644 --- a/packages/session/session-persistence-sqlite/README.md +++ b/packages/session/session-persistence-sqlite/README.md @@ -10,9 +10,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` p ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column, and nullable `time_zone` preserves an optional `timeZone` string. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. The one supported upgrade accepts an owned v13 database, adds nullable `time_zone`, and advances `user_version` to 14 inside the existing `BEGIN IMMEDIATE`; old rows remain `NULL`. A failure rolls back both changes. Non-pristine unversioned databases, foreign application identities, and every other version reject before journal-mode mutation. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations. On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. @@ -59,5 +59,5 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. -- **Only a pristine new database, an owned v13 database eligible for the v14 upgrade, or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected. +- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md index 8b98d24b33..c86531bcae 100644 --- a/packages/session/session-persistence-sqlite/README.zh.md +++ b/packages/session/session-persistence-sqlite/README.zh.md @@ -10,9 +10,9 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 提供方(见 ## 存储模型 -每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数,可为空的 `time_zone` 则保留可选的 `timeZone` 字符串。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。 +每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。 -仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。唯一受支持的升级接受自有 v13 数据库,在既有 `BEGIN IMMEDIATE` 中添加可为空的 `time_zone`,并将 `user_version` 推进到 14;旧行保持 `NULL`。失败会回滚这两项变更。非 pristine 无版本数据库、外部 application identity 和所有其他版本在 journal-mode 变更前均会被拒绝。 +仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前均会被拒绝,因为该未发布格式无迁移。 在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。 @@ -59,5 +59,5 @@ SQLite 存储不修改当前请求前缀。只有重建历史、当前 envelope - **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件循环;对本地存储可接受,对繁忙多会话服务器是吞吐上限。 - **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。 -- **只有 pristine 新数据库、符合 v14 升级条件的自有 v13 数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本都会被拒绝。 +- **只有 pristine 新数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。 - **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理配置)。 diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 0e55477f7c..fc2b10fa96 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -380,13 +380,12 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, time_zone, parent_session, seed_length, origin, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, - time_zone = excluded.time_zone, parent_session = excluded.parent_session, seed_length = excluded.seed_length, origin = excluded.origin, @@ -396,7 +395,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.version, meta.createdAt, meta.cwd ?? null, - meta.timeZone ?? null, meta.parentSession ?? null, meta.seedLength ?? null, meta.origin ?? null, diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index 0f9c32fba1..a9830316a8 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -17,55 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 14 - -/** The one owned schema layout this build upgrades in place. */ -const MIGRATABLE_SCHEMA_VERSION = 13 - -/** Exact user objects emitted by the v13 schema owner, before `time_zone`. */ -const MIGRATABLE_V13_SCHEMA = [ - { - type: 'table', - name: 'events', - tableName: 'events', - sql: `CREATE TABLE events ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - time INTEGER NOT NULL, - data TEXT NOT NULL, - source_event_seqs TEXT, - surface_op TEXT, - PRIMARY KEY (session_id, seq) - ) STRICT`, - }, - { - type: 'table', - name: 'persistence_state', - tableName: 'persistence_state', - sql: `CREATE TABLE persistence_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - store_id TEXT NOT NULL - ) STRICT`, - }, - { - type: 'table', - name: 'sessions', - tableName: 'sessions', - sql: `CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - created_at INTEGER NOT NULL, - cwd TEXT, - parent_session TEXT, - seed_length INTEGER, - origin TEXT, - delegation_depth INTEGER, - incarnation TEXT NOT NULL, - revision INTEGER NOT NULL - ) STRICT`, - }, -] as const +export const SCHEMA_VERSION = 13 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -82,7 +34,6 @@ export interface SessionRow { version: number created_at: number cwd: string | null - time_zone: string | null parent_session: string | null seed_length: number | null origin: 'subagent' | null @@ -117,9 +68,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' /** * Open the database and apply its schema and pragmas. An empty database with a - * zero `user_version` is initialized at {@link SCHEMA_VERSION}; an owned v13 - * database is upgraded atomically, while a nonempty unversioned database and - * every other non-current version reject. + * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty + * unversioned database and every other non-current version reject rather than + * being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. * @returns the open handle with pragmas applied and all three tables ensured. @@ -151,19 +102,14 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { throw new Error(`session database at "${path}" has an unversioned schema or application identity`) } - if (onDisk !== 0 && onDisk !== MIGRATABLE_SCHEMA_VERSION && onDisk !== SCHEMA_VERSION) { + if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } - if ((onDisk === MIGRATABLE_SCHEMA_VERSION || onDisk === SCHEMA_VERSION) - && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { + if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { throw new Error( `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, ) } - if (onDisk === MIGRATABLE_SCHEMA_VERSION) { - assertMigratableV13Schema(db, path) - db.exec('ALTER TABLE sessions ADD COLUMN time_zone TEXT') - } db.exec(` CREATE TABLE IF NOT EXISTS persistence_state ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), @@ -175,7 +121,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM version INTEGER NOT NULL, created_at INTEGER NOT NULL, cwd TEXT, - time_zone TEXT, parent_session TEXT, seed_length INTEGER, origin TEXT, @@ -200,8 +145,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM ).run(randomUUID()) if (onDisk === 0) { db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) - } - if (onDisk === 0 || onDisk === MIGRATABLE_SCHEMA_VERSION) { db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } db.exec('COMMIT') @@ -223,34 +166,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) } -/** Reject spoofed or modified v13 layouts before the migration changes them. */ -function assertMigratableV13Schema(db: DatabaseSync, path: string): void { - const objects = db.prepare(` - SELECT type, name, tbl_name AS tableName, sql - FROM sqlite_schema - WHERE name NOT GLOB 'sqlite_*' - ORDER BY type, name - `).all() as Array<{ type: string; name: string; tableName: string; sql: string | null }> - const matches = objects.length === MIGRATABLE_V13_SCHEMA.length - && objects.every((object, index) => { - const expected = MIGRATABLE_V13_SCHEMA[index] - return expected !== undefined - && object.type === expected.type - && object.name === expected.name - && object.tableName === expected.tableName - && object.sql !== null - && normalizeSchemaSql(object.sql) === normalizeSchemaSql(expected.sql) - }) - if (!matches) { - throw new Error(`session database at "${path}" does not match the owned v13 schema`) - } -} - -/** Ignore formatting while preserving every schema token and its order. */ -function normalizeSchemaSql(sql: string): string { - return sql.replace(/\s+/g, ' ').trim() -} - /** * Reconstruct the {@link SessionHeader} from a `sessions` row. * @param row - the `sessions` table row. @@ -265,7 +180,6 @@ export function rowToMeta(row: SessionRow): SessionHeader { id: row.id as SessionId, createdAt: row.created_at, ...row.cwd !== null ? { cwd: row.cwd } : {}, - ...row.time_zone !== null ? { timeZone: row.time_zone } : {}, ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.origin !== null ? { origin: row.origin } : {}, diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index c0afd1153d..ab602e1c4d 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -40,48 +40,6 @@ async function freshDbPath(): Promise { return join(dir, 'sessions.db') } -/** Create the exact owned v13 layout without passing through the v14 opener. */ -function createV13Database(path: string): DatabaseSync { - const db = new DatabaseSync(path) - db.exec(` - PRAGMA foreign_keys = ON; - - CREATE TABLE persistence_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - store_id TEXT NOT NULL - ) STRICT; - - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - created_at INTEGER NOT NULL, - cwd TEXT, - parent_session TEXT, - seed_length INTEGER, - origin TEXT, - delegation_depth INTEGER, - incarnation TEXT NOT NULL, - revision INTEGER NOT NULL - ) STRICT; - - CREATE TABLE events ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - time INTEGER NOT NULL, - data TEXT NOT NULL, - source_event_seqs TEXT, - surface_op TEXT, - PRIMARY KEY (session_id, seq) - ) STRICT; - - PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}; - PRAGMA user_version = 13; - `) - db.prepare('INSERT INTO persistence_state (singleton, store_id) VALUES (1, ?)').run('v13-fixture-store') - return db -} - /** A context with the session store + SQLite backend, plus a teardown. */ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise }> { const ctx = new Context() @@ -208,14 +166,13 @@ describe('rowToMeta', () => { version: 0, created_at: 1, cwd: null, - time_zone: 'Asia/Shanghai', parent_session: null, seed_length: null, origin: 'subagent', incarnation: 'with-origin', revision: 1, delegation_depth: null, - })).toMatchObject({ id: 'with-origin', origin: 'subagent', timeZone: 'Asia/Shanghai' }) + })).toMatchObject({ id: 'with-origin', origin: 'subagent' }) }) it('rejects fractional stored creation metadata', () => { @@ -224,7 +181,6 @@ describe('rowToMeta', () => { version: 0, created_at: 1.5, cwd: null, - time_zone: null, parent_session: null, seed_length: null, origin: null, @@ -372,7 +328,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b2.dispose() }) - it('rejects opening a database whose schema version is neither v13 nor the current build', async () => { + it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => { const path = await freshDbPath() openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION // Bump user_version past what this build supports. @@ -381,84 +337,16 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { dbNewer.close() expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/) - // Versions older than the one explicit migration remain unsupported. + // The immediately preceding layout lacks the required store identity and is + // rejected rather than migrated (unreleased software, no backward-compat). const olderPath = await freshDbPath() openDatabase(olderPath, 'wal').close() const dbOlder = openDatabase(olderPath, 'wal') - dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 2}`) + dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`) dbOlder.close() expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) - it('atomically migrates an owned v13 fixture and leaves old rows headerless', async () => { - const path = await freshDbPath() - const old = meta('v13-headerless', '/work') - const legacy = createV13Database(path) - legacy.prepare(` - INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, 1) - `).run(old.id, old.version, old.createdAt, old.cwd ?? null, 'v13-headerless-incarnation') - const insertEvent = legacy.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', - ) - for (const event of oneTurnLog()) { - const surface = event as SessionEvent - insertEvent.run( - old.id, - event.seq, - event.type, - event.time, - JSON.stringify(event.data), - surface.sourceEventSeqs !== undefined ? JSON.stringify(surface.sourceEventSeqs) : null, - surface.surfaceOp !== undefined ? JSON.stringify(surface.surfaceOp) : null, - ) - } - legacy.close() - - const migrated = openDatabase(path, 'wal') - expect(migrated.prepare('PRAGMA user_version').get()).toEqual({ user_version: 14 }) - expect(migrated.prepare('SELECT time_zone FROM sessions WHERE id = ?').get(old.id)) - .toEqual({ time_zone: null }) - migrated.close() - - const mounted = await backend(path) - try { - const loaded = await mounted.ctx.sessionPersistence.load(old.id) - expect(loaded.meta.timeZone).toBeUndefined() - expect(loaded.events).toEqual(oneTurnLog()) - - const zoned = meta('v14-zoned', '/work', 'Asia/Shanghai') - await mounted.ctx.sessionPersistence.create(zoned) - await mounted.ctx.sessionPersistence.append(zoned.id, oneTurnLog()) - expect((await mounted.ctx.sessionPersistence.load(zoned.id)).meta.timeZone).toBe('Asia/Shanghai') - } finally { - await mounted.dispose() - } - }) - - it('rejects a spoofed v13 layout without changing its schema or version', async () => { - const path = await freshDbPath() - const malformed = new DatabaseSync(path) - malformed.exec(` - CREATE TABLE sessions (id TEXT); - PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}; - PRAGMA user_version = 13; - `) - malformed.close() - - expect(() => openDatabase(path, 'wal')).toThrow(/does not match the owned v13 schema/) - - const unchanged = new DatabaseSync(path) - const columns = unchanged.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> - expect(columns.map(column => column.name)).toEqual(['id']) - expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 13 }) - expect(unchanged.prepare( - "SELECT name FROM sqlite_schema WHERE name IN ('persistence_state', 'events')", - ).all()).toEqual([]) - unchanged.close() - }) - it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => { const path = await freshDbPath() const legacy = new DatabaseSync(path) @@ -520,23 +408,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { unchangedApplication.close() }) - it.each([13, SCHEMA_VERSION])('rejects a schema-v%i database with a foreign application identity', async (version) => { + it('rejects a current-version database with a foreign application identity', async () => { const path = await freshDbPath() const foreign = new DatabaseSync(path) foreign.exec('PRAGMA application_id = 12345') - foreign.exec(`PRAGMA user_version = ${version}`) + foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) foreign.close() expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/) const unchanged = new DatabaseSync(path) expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 }) - expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: version }) + expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) unchanged.close() }) - it('rolls back tables created before persistence-state initialization fails', async () => { + it('rolls back schema objects and identity stamps when initialization fails', async () => { const path = await freshDbPath() const conflicting = new DatabaseSync(path) conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) @@ -571,11 +459,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(db.prepare('PRAGMA application_id').get()) .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID }) expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) - expect(db.prepare('PRAGMA table_info(sessions)').all()).toContainEqual(expect.objectContaining({ - name: 'time_zone', - type: 'TEXT', - notnull: 0, - })) db.close() }) @@ -755,7 +638,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(14) + expect(SCHEMA_VERSION).toBe(13) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index e90ad98156..c64826db1e 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable session-persistence Service Definition (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a Service provider in a sibling package, and Consumers that inject the service. -The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, optional time zone, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. +The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. ## Service API (`ctx.sessionPersistence`) @@ -33,9 +33,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure. -A live controller retains no seed copy. If first initialization rejects, the next flush borrows the current append-only Session log, rechecks the backend's actual cursor, and appends only the missing suffix before draining retained events. Concurrent retries share one initialization attempt; a committed-but-rejected write therefore neither duplicates the prefix nor permanently poisons the Session. - -Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session across backend reads and repair writes, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, requires exact stored/live cwd and optional-`timeZone` identity, and never closes the active turn. Normal resume reconstructs a headerless live Session from its stored header, so it remains zone-unavailable and is never backfilled; a zoned live object cannot adopt that prefix. +Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. @@ -56,11 +54,11 @@ The `PersistenceBackend` hooks (the only contract between the coordi | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The coordinator asserts the stored id and validates the optional stored `timeZone` as a string before repair or publication. Live adoption requires exact stored/live cwd and optional-zone equality, including headerless-to-headerless identity. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Metadata and location types -Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `timeZone?`, `parentSession?`, `seedLength?`, `origin?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn. +Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`, `origin?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn. ## Model Experience diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 949d586bde..ec4fb72aeb 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -589,9 +589,6 @@ export class PersistenceCoordinator { if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) { return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer')) } - if (snapshot.timeZone !== undefined && typeof snapshot.timeZone !== 'string') { - return Promise.reject(new TypeError('session metadata timeZone must be a string')) - } return this.serialize(snapshot.id, () => this.createCore(snapshot)) } @@ -804,7 +801,7 @@ export class PersistenceCoordinator { signal?.throwIfAborted() if (suffix === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, suffix.meta) - this.assertStoredHeader(suffix.meta) + this.assertVersion(suffix.meta) if (suffix.events.some(needsLegacyPrefix)) { const whole = await this.readStoredPrefix(id, signal) return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } @@ -826,7 +823,7 @@ export class PersistenceCoordinator { signal?.throwIfAborted() if (stored === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, stored.meta) - this.assertStoredHeader(stored.meta) + this.assertVersion(stored.meta) return { meta: structuredClone(stored.meta), events: snapshotStoredEvents(stored.events, id), @@ -840,7 +837,7 @@ export class PersistenceCoordinator { try { const { meta, events, revision, tornMarker } = stored this.assertStoredId(id, meta) - this.assertStoredHeader(meta) + this.assertVersion(meta) const storedEvents = adoptStoredEvents(events, id) // Preserve complete interrupted events and synthesize only missing closers. @@ -984,14 +981,10 @@ export class PersistenceCoordinator { } } - /** Validate fixed fields decoded from backend-owned storage. */ - private assertStoredHeader(meta: SessionHeader): void { + private assertVersion(meta: SessionHeader): void { if (meta.version !== SESSION_FORMAT_VERSION) { throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`) } - if (meta.timeZone !== undefined && typeof meta.timeZone !== 'string') { - throw new Error(`stored session "${meta.id}" timeZone must be a string`) - } } /** Reject backend metadata that is not bound to the requested session id. */ @@ -1001,17 +994,6 @@ export class PersistenceCoordinator { } } - /** Compare the immutable metadata fields that participate in live adoption identity. */ - private assertAdoptableIdentity(meta: SessionHeader, session: Session): void { - this.assertStoredHeader(meta) - if (meta.cwd !== session.header.cwd) { - throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) - } - if (meta.timeZone !== session.header.timeZone) { - throw new Error(`session "${session.header.id}" is already persisted with a different timeZone (persisted: ${String(meta.timeZone)}, live: ${String(session.header.timeZone)}) (id collision)`) - } - } - // --- write path (session/event → flush drain) --- private installWritePath(): void { @@ -1179,7 +1161,9 @@ export class PersistenceCoordinator { // the stored header's cwd. The seed guard then ensures the live events // reproduce the persisted prefix; otherwise a fresh session reusing the // id could have its leading events filtered as already written. - this.assertAdoptableIdentity(tracked.meta, session) + if (tracked.meta.cwd !== session.header.cwd) { + throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + } if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) } @@ -1230,7 +1214,10 @@ export class PersistenceCoordinator { private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { const { meta, events, tornMarker } = stored this.assertStoredId(session.header.id, meta) - this.assertAdoptableIdentity(meta, session) + if (meta.cwd !== session.header.cwd) { + throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + } + this.assertVersion(meta) const storedEvents = snapshotStoredEvents(events, session.header.id) if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) diff --git a/packages/session/session-persistence/tests/contract.ts b/packages/session/session-persistence/tests/contract.ts index 0d0a5eddd4..a672884e46 100644 --- a/packages/session/session-persistence/tests/contract.ts +++ b/packages/session/session-persistence/tests/contract.ts @@ -21,13 +21,12 @@ export interface ContractBackend { } /** Build a minimal {@link SessionHeader} for a session id. */ -export function meta(id: string, cwd?: string, timeZone?: string): SessionHeader { +export function meta(id: string, cwd?: string): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1000, ...cwd !== undefined ? { cwd } : {}, - ...timeZone !== undefined ? { timeZone } : {}, } } @@ -87,49 +86,19 @@ export function runPersistenceContract(name: string, make: () => Promise { const { persistence, dispose } = await make() try { - const m = meta('s1', '/work', 'Asia/Shanghai') + const m = meta('s1', '/work') const log = oneTurnLog() await persistence.create(m) await persistence.append(m.id, log) const loaded = await persistence.load(m.id) - expect(loaded.meta).toMatchObject(m) + expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' }) expect(loaded.events).toEqual(log) } finally { await dispose() } }) - it('keeps a headerless session headerless across storage reads', async () => { - const { persistence, dispose } = await make() - try { - const m = meta('headerless', '/work') - await persistence.create(m) - await persistence.append(m.id, oneTurnLog()) - - expect((await persistence.inspect(m.id)).meta.timeZone).toBeUndefined() - expect((await persistence.load(m.id)).meta.timeZone).toBeUndefined() - expect((await persistence.list()).find(header => header.id === m.id)?.timeZone).toBeUndefined() - } finally { - await dispose() - } - }) - - it('rejects non-string timeZone metadata without reserving its session id', async () => { - const { persistence, dispose } = await make() - try { - const invalid = { ...meta('invalid-time-zone'), timeZone: 1 as unknown as string } - await expect(persistence.create(invalid)).rejects.toThrow('session metadata timeZone must be a string') - - const valid = meta('invalid-time-zone', undefined, 'UTC') - await persistence.create(valid) - await persistence.append(valid.id, oneTurnLog()) - expect((await persistence.load(valid.id)).meta.timeZone).toBe('UTC') - } finally { - await dispose() - } - }) - it('rejects a fractional creation timestamp without reserving its session id', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index 1e8ed74667..411df34d8d 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -908,68 +908,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('stored-prefix adoption rejects a different present timeZone', async () => { - const fix = await makeFixture() - const first = await freshCtx(fix) - try { - const stored = first.ctx.sessions.create(SessionId('zone-adoption'), { - meta: { cwd: WORK, timeZone: 'Asia/Shanghai' }, - }) - send(stored, oneTurnLog()) - await first.ctx.sessions.flush(stored) - } finally { - await first.fiber.dispose() - } - - const ctx = new Context() - await ctx.plugin(SessionStore) - const live = ctx.sessions.create(SessionId('zone-adoption'), { - seed: oneTurnLog(), - meta: { cwd: WORK, timeZone: 'America/New_York' }, - }) - const second = await fix.mount(ctx) - try { - await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/) - } finally { - await second.dispose() - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - - it('stored-prefix adoption rejects a zoned live session for a headerless record', async () => { - const fix = await makeFixture() - const log = [ - ...oneTurnLog(), - { type: 'session/end-seed', seq: 6, time: 7, data: {} }, - ] as SessionEvent[] - const first = await freshCtx(fix) - try { - const stored = first.ctx.sessions.create(SessionId('headerless-zone-adoption'), { - seed: log, - meta: { cwd: WORK }, - }) - await first.ctx.sessions.flush(stored) - } finally { - await first.fiber.dispose() - } - - const ctx = new Context() - await ctx.plugin(SessionStore) - const live = ctx.sessions.create(SessionId('headerless-zone-adoption'), { - seed: log, - meta: { cwd: WORK, timeZone: 'Asia/Shanghai' }, - }) - const second = await fix.mount(ctx) - try { - await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/) - } finally { - await second.dispose() - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => { const fix = await makeFixture() const ctx = new Context() @@ -1166,54 +1104,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('a zoned live session cannot claim headerless ownerless state', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - await ctx.sessionPersistence.create(meta('headerless-zone-claim', WORK)) - const live = ctx.sessions.create(SessionId('headerless-zone-claim'), { - seed: oneTurnLog(), - meta: { cwd: WORK, timeZone: 'Asia/Shanghai' }, - }) - - await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('ownerless state with a timeZone only accepts the same live identity', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - await ctx.sessionPersistence.create(meta('same-zone-claim', WORK, 'Asia/Shanghai')) - const matching = ctx.sessions.create(SessionId('same-zone-claim'), { - seed: oneTurnLog(), - meta: { cwd: WORK, timeZone: 'Asia/Shanghai' }, - }) - await expect(ctx.sessions.flush(matching)).resolves.toBe(true) - expect((await ctx.sessionPersistence.load(matching.id)).meta.timeZone).toBe('Asia/Shanghai') - - await ctx.sessionPersistence.create(meta('different-zone-claim', WORK, 'Asia/Shanghai')) - const conflicting = ctx.sessions.create(SessionId('different-zone-claim'), { - seed: oneTurnLog(), - meta: { cwd: WORK, timeZone: 'America/New_York' }, - }) - await expect(ctx.sessions.flush(conflicting)).rejects.toThrow(/different timeZone|id collision/) - - await ctx.sessionPersistence.create(meta('missing-zone-claim', WORK, 'Asia/Shanghai')) - const missing = ctx.sessions.create(SessionId('missing-zone-claim'), { - seed: oneTurnLog(), - meta: { cwd: WORK }, - }) - await expect(ctx.sessions.flush(missing)).rejects.toThrow(/different timeZone|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 018a37fb92..d3e715b085 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -374,28 +374,6 @@ describe('PersistenceCoordinator bounded writes', () => { }) describe('PersistenceCoordinator stored identity', () => { - it('rejects a non-string timeZone decoded by a backend', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new ControlledBackend() - const id = SessionId('invalid-stored-zone') - backend.store.set(id, { - meta: { ...meta(id), timeZone: 1 as unknown as string }, - events: [], - }) - let coordinator!: PersistenceCoordinator - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) - }, { inject: ['sessions'] })) - try { - await expect(coordinator.inspect(id)).rejects.toThrow(/stored session .* timeZone must be a string/) - expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - } - }) - it('rejects a mismatched backend header before repair or state publication', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7e6f77099..774a381761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-time-context': + specifier: workspace:^ + version: link:../../packages/context/time-context '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ version: link:../../packages/context/tmux-context