refactor(schedule): simplify request zone authority

This commit is contained in:
pku-xht
2026-08-06 19:33:19 +08:00
committed by Tianyi Cui
parent a667ec55d6
commit cd59acd6f6
25 changed files with 466 additions and 1007 deletions
@@ -10,19 +10,19 @@ A request-only clock can tell the model the current time, but replacing that val
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 authorities: 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.
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.
## 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 consumes its authority.
`@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.
When a reading is due, a prepended `system-prompt/assemble` listener opens a narrow authority envelope in the ordinary next-step inbox. It captures the already-claimed messages, and each user steering insertion admitted during asynchronous assembly synchronously stages a superseding authority. AgentLoop includes non-authority messages inside the closed envelope in the downstream `agent/pre-step` proposal, so ordinary guards, edits, discards, and filtering see the late input.
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.
After downstream pre-step transformations settle, time-context derives one final authority from the returned messages. An entering step appends those messages and only the final authority after `step/start`, before request derivation. An empty decision consumes the envelope without opening a request. A rejection, throw, or cancellation removes the envelope before the failed turn closes and may settle an already-sampled final authority inside that turn; append rejection drops it instead of leaking it. Disposal removes pending authorities and prevents an in-flight listener from contributing after disposal.
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 and receive a reading. 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 reading's strict source is `{ kind: 'plugin', plugin: 'time-context', authority }`. The authority identifies the proposed turn and step, reports the immutable `SessionHeader.timeZone` as `resolved` or `unavailable`, and folds the final request chain's browser provenance into `resolved`, sorted `mixed`, or `missing`. 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 its machine Session authority remains `unavailable`. Every explicit or Session-owned IANA zone is validated through `Intl.DateTimeFormat`.
Each reading has the simple source `{ kind: 'plugin', plugin: 'time-context' }`. 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`.
The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. 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 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.
### Text and elapsed baselines
@@ -50,13 +50,13 @@ Their baseline is the durable event timestamp of the preceding time-context mess
### Durability and request 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 strict source makes the same Session and request-zone authority available to typed consumers such as Schedule without parsing model-facing text.
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.
The plugin uses system-prompt assembly only as the bounded preparation window; it 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 failed no-step preparation may retain its already-sampled authority without transmitting a request.
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, resolved/mixed/missing client authority, 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, late steering, edit and discard, empty suppression, append rejection, default and keep-inbox cancellation, in-flight disposal, source decoding, 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 authority through the assembled browser path.
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.
## Alternatives considered
@@ -66,13 +66,14 @@ Unit and real-loop tests pin formatting, Session/fallback display zones, resolve
- **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.
- **Use the process zone or most recent browser as request authority** — 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 authority consumer.
- **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.
## Consequences
- Omission or `0` records every eligible preparation attempt; 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, including an already-sampled preparation reading settled inside a turn that opens no step.
- 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.
- Session authority is immutable and request authority 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 machine-readable as `unavailable`; elapsed time still uses durable harness append boundaries rather than client-origin timestamps.
- 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.
@@ -33,15 +33,15 @@ An Agent-scoped FIFO serializes each accepted management transaction and the liv
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.
### Session and request time-zone authority
### Session and request time-zone ownership
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.
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 opens a request-authority envelope at system-prompt assembly. Its model-visible reading uses the Session zone for the current date, local time, and offset, while its machine source names the proposed turn and step plus Session `resolved`/`unavailable` and client `resolved`/`mixed`/`missing` state. Steering admitted during asynchronous assembly is followed synchronously by a same-step superseding authority; the model and Schedule tool both consume the last authority for that turn and step. AgentLoop drains only the closed envelope that begins and ends with those authority messages. If the proposed step exits before `step/start`, it settles appendable authority inside the failed turn or removes authority that cannot be appended, while preserving the existing steering policy, so an old turn/step authority cannot leak into a later request.
Time-context delegates through `agent/pre-step`, derives the final entered request's zones from the immutable Session header and message-bound browser sources, and appends one model-visible reading to an entered step. 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, cancellation, or failure before `step/start` records no reading, and this feature adds no inbox or AgentLoop lifecycle state.
An implicit local `at` is accepted only when the final authority has one resolved client zone equal to the resolved 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.
Schedule requires a current-step time-context marker, then derives request zones directly from the open 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
@@ -107,7 +107,7 @@ The design does not recognize or migrate any unmerged Schedule implementation or
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 and AgentLoop lifecycle tests cover queued, edited, discarded, cancelled, and retried input; mixed tabs; delayed assembly with late steering; pre-step hook, assembly, checkpoint, append, and disposal failures; same-step last-authority selection; and non-leakage into the next turn. 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.
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, simple source validation, and in-flight disposal. Schedule tests independently derive the same request zones from durable `user-rpc` sources and fail closed without a current-step 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.
## Consequences
@@ -33,15 +33,15 @@ Status: implemented
每次成功的管理 preflight 也会要求 live owner 重新计算。这闭合了 create 已成功追加、但 post-append barrier 拒绝时的恢复路径:后续 list 可以确认 coordinator 保留的 batch、返回活动 record,并在没有 Schedule 私有重试循环的情况下 arm timer。
### Session 与请求时区权威
### Session 与请求时区归属
官方 Web create 路径要求浏览器提供 IANA 时区,在 Host 边界校验并规范化后,将其一次性存为不可变的 `SessionHeader.timeZone`。resume 保留该值,fork 复制该值;若针对相同 id 与 cwd 的另一次 create 得到的规范化时区不同,则发生冲突。Session core 保持该字段可选,使时区支持前的 Session 仍可读取,但其时区明确为 `unavailable`;绝不会用后续浏览器请求回填 legacy header。JSONL 保留该可选 headerSQLite schema v14 增加 nullable `time_zone`,并以原子方式升级自有 v13 数据库,不为既有行猜测值。
每条 Web 提示词都会单独采样自己的 `clientTimeZone`Host 在进入 Agent 前校验该值,并把它绑定到不可变的 `user-rpc` 消息来源。它是请求 provenance,而不是连接或 Session 的可变属性,因此并发 tab 无法相互覆盖,排队、steering(中途引导)、编辑、重试和持久化 history 都会保留来源时区。
Time-context 在系统提示词组装时打开请求权威包络。它向模型显示的读数按照 Session 时区给出当前日期、本地时间和 offset;机器源则标明拟议的轮次与步骤,以及 Session 的 `resolved``unavailable` 状态和 client 的 `resolved``mixed``missing` 状态。异步组装期间获准进入的 steering 后面,会同步追加同一步骤的取代权威;模型与 Schedule 工具都使用该轮次和步骤的最后一条权威。AgentLoop 只排空以这些权威消息为首尾的闭合包络。如果拟议步骤在 `step/start` 前退出,AgentLoop 会在失败轮次内结算可追加的权威消息,或移除无法追加的权威消息,同时保留既有 steering 政策,从而防止旧轮次/步骤的权威泄漏到后续请求
Time-context 会委托 `agent/pre-step`,从不可变 Session header 和与消息绑定的浏览器来源派生最终进入请求的时区,再向已经进入的步骤追加一条模型可见读数。其来源仍是简单插件标记,不会把这些事实复制成另一份持久权威。AgentLoop 领取当前批次后才插入的 steering(中途引导)保留常规 next-step 归属,并在该步骤进入时获得新上下文。`step/start` 之前发生 reject、取消或失败时,不会记录读数;本功能也不增加 inbox 或 AgentLoop 生命周期状态
只有最终权威包含一个已解析的 client 时区,且它等于已解析的 Session 时区,才会接受隐式 local `at`。无 header 的 Session、client provenance 缺失或 mixed,或 clientSession 不匹配,都会返回 `timezone_confirmation_required` 及已知时区。显式 `time_zone` 可绕过这项歧义检查,但仍要通过相同的 IANA 校验。
Schedule 要求当前步骤存在 time-context 标记,然后直接从 open turn 的原始 `user-rpc` 来源派生请求时区。只有派生结果包含一个与 Session 时区相等的 client 时区,才会接受隐式 local `at`。无 header 的 Session、client provenance 缺失或 mixed,或 clientSession 不匹配,都会返回 `timezone_confirmation_required` 及已知时区。显式 `time_zone` 可绕过这项歧义检查,但仍要通过相同的 IANA 校验。
### 绝对时间规范化
@@ -69,7 +69,7 @@ Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact
已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 viewraw 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` 提醒行。
浏览器 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
@@ -107,7 +107,7 @@ due → admission → followup → dispatch → flush(true) → session/flushed
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 与 AgentLoop 生命周期测试覆盖已排队、已编辑、已丢弃、已取消和已重试的输入;混合 tab;带有晚到 steering 的延迟组装;pre-step 钩子、组装、检查点、追加与处置阶段的失败;同一步骤内选择最后一条权威;以及权威不会泄漏到下一轮次。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对既有的短 `after` case 和一个 absolute-time case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。
Time-context 测试覆盖最终 pre-step 消息、当前 turn 的唯一/混合/缺失时区派生、领取后 steering 进入下一步骤、取消、空值抑制、重试、简单来源校验和执行中释放。Schedule 测试会从持久 `user-rpc` 来源独立派生同一组请求时区,并在缺少当前步骤标记时 fail closed。显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline,针对既有的短 `after` case 和一个 absolute-time case 执行 `schedule_create`,观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。
## 后果
+8 -8
View File
@@ -122,15 +122,15 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', ()
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 authority = agentHandle.agent.session.events.find(event =>
const timeReading = agentHandle.agent.session.events.find(event =>
event.type === 'user/message'
&& (event.data as { source?: { plugin?: unknown } }).source?.plugin === 'time-context')?.data as {
source?: { authority?: unknown }
} | undefined
expect(authority?.source?.authority).toMatchObject({
session: { kind: 'resolved', timeZone: SESSION_TIME_ZONE },
client: { kind: 'missing' },
})
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context')
if (timeReading?.type !== 'user/message') throw new Error('missing time-context reading')
expect(timeReading.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
const timeText = timeReading.data.content.find(block => block.type === 'text')?.text
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: {},
})
+7 -7
View File
@@ -85,13 +85,13 @@ forever:
-> 'turn/start'
claim next-step input plus one next-turn message
-> emit agent/inbox/claimed({ message, turn }) for each claimed message
-> assemble system prompt; providers may stage a bounded preparation envelope
-> agent/pre-step({ agent, messages: claimed + staged non-authority messages, turn, step, signal })
-> assemble system prompt
-> agent/pre-step({ agent, messages, turn, step, signal })
reject, empty input, cancellation, or listener failure
-> remove the preparation envelope; close the no-step turn; stop the driver
-> the claimed batch stays removed; close the no-step turn; stop the driver
enter -> step loop:
'step/start'
append the returned batch and final preparation authority as separate 'user/message' events
append the returned batch as separate 'user/message' events
render the assembled prompt and tool schemas -> snapshot derived messages
agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound)
'assistant/chunk'
@@ -103,7 +103,7 @@ forever:
model-order result -> ordered tools/post-execute -> 'tool/result'
'step/end'
tools owe another request or next-step inbox is nonempty
-> claim -> assemble -> agent/pre-step -> append entered batch -> continue
-> claim -> agent/pre-step -> append entered batch -> continue
otherwise agent/turn-stopping -> re-check the next-step inbox
'turn/end'
start the next waking queued message, or emit agent/status(idle)
@@ -113,9 +113,9 @@ idle inject:
leave it pending until followup or steer wakes the driver
```
Each proposed step assembles ordered prompt sections, tool schemas, and variables before pre-step; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch, any ordinary messages inside a bounded assembly envelope, and the upcoming turn, step, and signal. Preparation authorities stay outside downstream transformations; an accepted step appends only the final authority after the returned batch. Reject opens no step, an empty decision cannot be revived by authority alone, and a failed preparation removes its envelope before the turn closes. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites.
`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch and upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites.
Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry of the frozen prompt; cancellation wins. Adapter `retryPolicy` bounds normal mode, while always mode retries after specialized recovery ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics.
+7 -7
View File
@@ -85,13 +85,13 @@ forever:
-> 'turn/start'
claim next-step input plus one next-turn message
-> emit agent/inbox/claimed({ message, turn }) for each claimed message
-> assemble system prompt; providers may stage a bounded preparation envelope
-> agent/pre-step({ agent, messages: claimed + staged non-authority messages, turn, step, signal })
-> assemble system prompt
-> agent/pre-step({ agent, messages, turn, step, signal })
reject, empty input, cancellation, or listener failure
-> remove the preparation envelope; close the no-step turn; stop the driver
-> the claimed batch stays removed; close the no-step turn; stop the driver
enter -> step loop:
'step/start'
append the returned batch and final preparation authority as separate 'user/message' events
append the returned batch as separate 'user/message' events
render the assembled prompt and tool schemas -> snapshot derived messages
agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound)
'assistant/chunk'
@@ -103,7 +103,7 @@ forever:
model-order result -> ordered tools/post-execute -> 'tool/result'
'step/end'
tools owe another request or next-step inbox is nonempty
-> claim -> assemble -> agent/pre-step -> append entered batch -> continue
-> claim -> agent/pre-step -> append entered batch -> continue
otherwise agent/turn-stopping -> re-check the next-step inbox
'turn/end'
start the next waking queued message, or emit agent/status(idle)
@@ -113,9 +113,9 @@ idle inject:
leave it pending until followup or steer wakes the driver
```
每个拟议步骤都会在 pre-step 前组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider``model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
每个步骤都会组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider``model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()``steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次、有界组装 envelope 中的普通消息,以及即将使用的轮次、步骤和信号。准备权威不进入下游转换;获准进入的步骤会在返回批次后仅追加最终权威。拒绝则不进入步骤,空决策不能仅凭权威重新激活,准备失败则会在轮次关闭前移除其 envelope。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。
`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()``steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。
裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以授权使用冻结提示词进行同步骤重试;取消优先。适配器的 `retryPolicy` 使 normal mode 保持有界,always mode 则在专门恢复后重试([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义;队列、steering(中途引导)、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义。
+1 -1
View File
@@ -1884,7 +1884,7 @@ export interface Config {
}
```
Source: [`packages/context/time-context/src/index.ts:34`](../packages/context/time-context/src/index.ts)
Source: [`packages/context/time-context/src/index.ts:28`](../packages/context/time-context/src/index.ts)
## `@deepseek-ai/dsh-tmux-context`
+13 -13
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Opt-in durable context with the current zoned time, Session and request-zone authority, 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, 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).
## Config
@@ -11,28 +11,28 @@ Opt-in durable context with the current zoned time, Session and request-zone aut
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 eligible attempt
refreshIntervalMs: 60000 # optional; omit or set to 0 for every entered request step
```
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.
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible request preparation whose final pre-step decision contains input and 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 entered request 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 have elapsed since the latest injection.
## Timing semantics
The plugin opens a narrow authority envelope in `system-prompt/assemble` and closes it around `agent/pre-step`. It captures already-claimed input, and each user steering message admitted during asynchronous assembly is followed synchronously by a superseding same-step authority. AgentLoop includes the envelope's non-authority messages in the downstream pre-step proposal; after downstream edits, discards, or filtering settle, time-context derives the final authority from that decision.
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a request step, time-context derives client zones from the decision's 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.
An entering step records its downstream messages followed by exactly one final time-context `UserMessage` after `step/start`. Its source is `{ kind: 'plugin', plugin: 'time-context', authority }`, where `authority` identifies the proposed turn and step, the Session zone as `resolved` or `unavailable`, and the current request's client zones as `resolved`, `mixed`, or `missing`. An empty downstream decision consumes the envelope without opening a step or request.
An entering step records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A first-step decision rewritten to empty opens no step and adds no reading. An empty tool continuation can still enter a later step and receives a reading.
If preparation exits before `step/start`, AgentLoop removes the envelope before closing the turn. It may settle an appendable final authority inside that failed turn, but an append failure drops the authority instead of leaving it pending. Cancellation cannot generate another authority after it wins; plugin disposal removes pending authorities and an in-flight listener contributes nothing after disposal. Steering and unrelated inbox work retain their ordinary cancellation policy, and no authority for an old turn or step can leak into a later request.
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 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 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 request preparation, not a completed step or transmitted request. A later request-preparation failure can therefore leave the reading in history, and a no-step failure can settle an already-sampled authority inside its failed turn.
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 strictly decodes each plugin-attributed authority and checks it against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
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 interval suppression can let a request reuse existing history without adding one.
@@ -42,7 +42,7 @@ The time reading stays in derived conversation history until a later compaction
#### What the model sees
On each preparation attempt that injects, one source-tagged context message contains the four lines below. `<timestamp>` 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`. Positive intervals can leave an attempted step without a new reading.
On each entered step that injects, one source-tagged context message contains the four lines below. `<timestamp>` 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`. Positive intervals can let an entered step reuse prior history without a new reading.
##### First step
@@ -64,7 +64,7 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
#### Token effect
Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
Each injected four-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every entered request step.
#### KV Cache effect
@@ -74,6 +74,6 @@ Append-only; newly visible content follows the reusable request prefix and does
- **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 Session authority 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 per-request client authority reports disagreement instead of silently changing the displayed default.
- **History cost between compactions** — omission or `0` retains one reading for every eligible preparation attempt, including attempts later cancelled or failed; a positive interval reduces but does not eliminate this cost.
- **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 entered request step, including steps whose later request preparation fails; a positive interval reduces but does not eliminate this cost.
@@ -21,6 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/request-zone-*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
@@ -1,135 +0,0 @@
/** Machine-readable Session and request-zone authority carried by time-context messages. */
/** Session-owned zone authority included in each time-context reading. */
export type SessionTimeZoneAuthority =
| { readonly kind: 'resolved'; readonly timeZone: string }
| { readonly kind: 'unavailable' }
/** Client-zone provenance of the messages entering one proposed step. */
export type ClientTimeZoneAuthority =
| { readonly kind: 'resolved'; readonly timeZone: string }
| { readonly kind: 'mixed'; readonly timeZones: string[] }
| { readonly kind: 'missing' }
/** Machine-readable time authority shared by model context and Schedule tools. */
export interface TimeContextAuthority {
readonly turn: number
readonly step: number
readonly session: SessionTimeZoneAuthority
readonly client: ClientTimeZoneAuthority
}
/** Source shape owned by the time-context plugin. */
export interface TimeContextMessageSource {
kind: 'plugin'
plugin: 'time-context'
authority: TimeContextAuthority
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'time-context': TimeContextMessageSource
}
}
/** Whether an unknown value is one ordinary JSON object. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Require one object to carry exactly the named keys. */
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(value).sort()
const wanted = [...expected].sort()
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index])
}
/** Decode one non-empty zone name without re-owning Host canonicalization. */
function zone(value: unknown): string {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError('time-context authority time zone must be a non-empty string')
}
return value
}
/** Decode the Session branch of one authority value. */
function sessionAuthority(value: unknown): SessionTimeZoneAuthority {
if (!isRecord(value)) throw new TypeError('time-context Session authority must be an object')
if (value['kind'] === 'unavailable' && hasExactKeys(value, ['kind'])) return { kind: 'unavailable' }
if (value['kind'] === 'resolved' && hasExactKeys(value, ['kind', 'timeZone'])) {
return { kind: 'resolved', timeZone: zone(value['timeZone']) }
}
throw new TypeError('time-context Session authority has an invalid shape')
}
/** Decode the request-client branch of one authority value. */
function clientAuthority(value: unknown): ClientTimeZoneAuthority {
if (!isRecord(value)) throw new TypeError('time-context client authority must be an object')
if (value['kind'] === 'missing' && hasExactKeys(value, ['kind'])) return { kind: 'missing' }
if (value['kind'] === 'resolved' && hasExactKeys(value, ['kind', 'timeZone'])) {
return { kind: 'resolved', timeZone: zone(value['timeZone']) }
}
if (value['kind'] === 'mixed' && hasExactKeys(value, ['kind', 'timeZones'])) {
const values = value['timeZones']
if (!Array.isArray(values)
|| !values.every((item): item is string => typeof item === 'string' && item.length > 0)
|| values.length < 2) {
throw new TypeError('time-context mixed client authority must contain at least two zones')
}
const timeZones = [...new Set(values)].sort()
if (timeZones.length !== values.length || timeZones.some((item, index) => item !== values[index])) {
throw new TypeError('time-context mixed client zones must be unique and sorted')
}
return { kind: 'mixed', timeZones }
}
throw new TypeError('time-context client authority has an invalid shape')
}
/**
* Decode the strict durable source attached to a time-context message.
* @param value - Untrusted message source.
* @returns Detached machine authority and its fixed plugin discriminator.
*/
export function decodeTimeContextSource(value: unknown): TimeContextMessageSource {
if (!isRecord(value) || !hasExactKeys(value, ['kind', 'plugin', 'authority'])
|| value['kind'] !== 'plugin' || value['plugin'] !== 'time-context') {
throw new TypeError('time-context message source has an invalid shape')
}
const authority = value['authority']
if (!isRecord(authority) || !hasExactKeys(authority, ['turn', 'step', 'session', 'client'])) {
throw new TypeError('time-context authority has an invalid shape')
}
const turn = authority['turn']
const step = authority['step']
if (!Number.isSafeInteger(turn) || (turn as number) < 1
|| !Number.isSafeInteger(step) || (step as number) < 1) {
throw new TypeError('time-context authority turn and step must be positive safe integers')
}
return {
kind: 'plugin',
plugin: 'time-context',
authority: {
turn: turn as number,
step: step as number,
session: sessionAuthority(authority['session']),
client: clientAuthority(authority['client']),
},
}
}
/**
* Render the machine authority as concise model-visible policy.
* @param authority - Session and request-zone authority for one proposed step.
* @returns The two policy lines appended to the time-context reading.
*/
export function renderTimeContextAuthority(authority: TimeContextAuthority): string {
const session = authority.session.kind === 'resolved'
? authority.session.timeZone
: 'unavailable'
const client = authority.client.kind === 'resolved'
? authority.client.timeZone
: authority.client.kind === 'mixed'
? `mixed ${JSON.stringify(authority.client.timeZones)}`
: 'missing'
return `Session time zone: ${session}.\nClient time zone for this request: ${client}.`
}
+51 -322
View File
@@ -10,19 +10,13 @@ import z from 'schemastery'
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 { renderTimeContextAuthority } from './authority.ts'
import type {
ClientTimeZoneAuthority,
TimeContextAuthority,
} from './authority.ts'
import {
deriveClientTimeZoneContext,
renderTimeZoneContext,
} from './request-zone.ts'
export type {
ClientTimeZoneAuthority,
SessionTimeZoneAuthority,
TimeContextAuthority,
TimeContextMessageSource,
} from './authority.ts'
export { decodeTimeContextSource, renderTimeContextAuthority } from './authority.ts'
export type { ClientTimeZoneContext } from './request-zone.ts'
export { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'time-context'
@@ -44,7 +38,6 @@ export const Config: z<Config> = z.object({
refreshIntervalMs: z.number(),
})
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
@@ -73,7 +66,7 @@ function formatDuration(elapsedMs: number): string {
return parts.join(' ')
}
/** Find the latest model-visible event, excluding this plugin's pending append. */
/** Find the latest model-visible event before the current proposal. */
function precedingMessageTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
@@ -114,40 +107,18 @@ function latestInjectionTime(agent: Agent): number | undefined {
return undefined
}
/** Read the Host-validated client zone from one ordinary user-rpc message. */
function clientTimeZone(message: UserMessage): string | undefined {
const source = message.source
return source.kind === 'user'
&& 'clientTimeZone' in source
&& typeof source.clientTimeZone === 'string'
? source.clientTimeZone
: undefined
}
/** Derive all distinct client zones in the current request chain. */
function requestClientTimeZones(agent: Agent, turn: number, messages: readonly UserMessage[]): string[] {
const zones = new Set<string>()
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) break
if (event.type !== 'user/message') continue
const zone = clientTimeZone(event.data)
if (zone !== undefined) zones.add(zone)
}
for (const message of messages) {
const zone = clientTimeZone(message)
if (zone !== undefined) zones.add(zone)
}
return [...zones].sort()
}
/** Close the request-zone set into the machine authority union. */
function clientAuthority(timeZones: string[]): ClientTimeZoneAuthority {
const [timeZone, ...remaining] = timeZones
if (timeZone === undefined) return { kind: 'missing' }
if (remaining.length === 0) return { kind: 'resolved', timeZone }
return { kind: 'mixed', timeZones }
/** Collect already-entered and proposed 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] : [])
return [...entered, ...proposed]
}
/** Render one durable time reading. */
function renderText(
now: number,
turn: number,
@@ -155,75 +126,17 @@ function renderText(
previous: number | undefined,
formatter: Intl.DateTimeFormat,
displayTimeZone: string,
authority: TimeContextAuthority,
sessionTimeZone: string | undefined,
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`
+ `${renderTimeContextAuthority(authority)}\n`
+ `${renderTimeZoneContext(sessionTimeZone, client)}\n`
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
}
interface PreparationPosition {
turn: number
step: number
}
interface ClaimedPreparation extends PreparationPosition {
messages: UserMessage[]
}
interface AssemblyAuthorityState extends PreparationPosition {
agent: Agent
claimed: readonly UserMessage[]
deferredIds: Set<string>
handledIds: Set<string>
accepting: boolean
lastFingerprint?: string
lastMessageId?: UserMessage['id']
readonly signal: AbortSignal
readonly onAbort: () => void
}
/** Derive the next unopened step while one turn is in pre-step preparation. */
function preparationPosition(agent: Agent): PreparationPosition | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
case 'step/start':
case 'turn/end':
return undefined
case 'step/end':
return { turn: event.data.turn, step: event.data.step + 1 }
case 'turn/start':
return { turn: event.data.turn, step: 1 }
default:
break
}
}
return undefined
}
/** Whether two preparation coordinates identify the same unopened step. */
function samePosition<T extends PreparationPosition>(
left: T | undefined,
right: PreparationPosition,
): left is T {
return left?.turn === right.turn && left.step === right.step
}
/** Whether one message is a time-context reading for an exact preparation. */
function isAuthorityMessage(
message: UserMessage,
position: PreparationPosition,
): boolean {
const source = message.source
return source.kind === 'plugin'
&& source.plugin === name
&& 'authority' in source
&& source.authority.turn === position.turn
&& source.authority.step === position.step
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
@@ -238,9 +151,10 @@ 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.
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
* @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.
*/
export function apply(ctx: Context, config: Config): () => void {
const timeZone = config.timeZone
@@ -268,8 +182,6 @@ export function apply(ctx: Context, config: Config): () => void {
}
const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
const claimedPreparations = new Map<Agent, ClaimedPreparation>()
const assemblyAuthorities = new Map<Agent, AssemblyAuthorityState>()
let disposed = false
/** Resolve one Session-owned formatter without making the process zone authoritative. */
@@ -286,200 +198,52 @@ export function apply(ctx: Context, config: Config): () => void {
return created
}
/** Build one current reading without placing it in the inbox or decision. */
/** Build one current reading after downstream pre-step transforms settle. */
const readingFor = (
agent: Agent,
position: PreparationPosition,
turn: number,
step: number,
messages: readonly UserMessage[],
): { message: UserMessage; fingerprint: string } => {
): UserMessage => {
const now = Date.now()
const previous = position.step === 1
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, position.turn)
: precedingStepContextTime(agent, turn)
const sessionTimeZone = agent.session.header.timeZone
const authority: TimeContextAuthority = {
turn: position.turn,
step: position.step,
session: sessionTimeZone === undefined
? { kind: 'unavailable' }
: { kind: 'resolved', timeZone: sessionTimeZone },
client: clientAuthority(requestClientTimeZones(agent, position.turn, messages)),
}
const displayTimeZone = sessionTimeZone ?? fallbackTimeZone
const formatter = sessionTimeZone === undefined
? fallbackFormatter
: formatterFor(sessionTimeZone)
return {
message: createUserMessage({
content: [{
type: 'text',
text: renderText(
now,
position.turn,
position.step,
previous,
formatter,
displayTimeZone,
authority,
),
}],
source: { kind: 'plugin', plugin: name, authority },
}),
fingerprint: JSON.stringify(authority),
}
return createUserMessage({
content: [{
type: 'text',
text: renderText(
now,
turn,
step,
previous,
formatter,
displayTimeZone,
sessionTimeZone,
requestMessages(agent, turn, messages),
),
}],
source: { kind: 'plugin', plugin: name },
})
}
/** Messages added after assembly opened, excluding deferred pre-existing work. */
const assemblyMessages = (state: AssemblyAuthorityState): UserMessage[] =>
state.agent.inbox.nextStep.filter(message => !state.deferredIds.has(message.id))
/** Stop accepting late steering while retaining the state for boundary cleanup. */
const closeAssembly = (state: AssemblyAuthorityState): void => {
state.accepting = false
}
/** Forget one preparation and detach its cancellation observer. */
const clearAssembly = (agent: Agent, state = assemblyAuthorities.get(agent)): void => {
if (state === undefined) return
state.accepting = false
state.signal.removeEventListener('abort', state.onAbort)
if (assemblyAuthorities.get(agent) === state) assemblyAuthorities.delete(agent)
}
/** Append one same-step authority after the messages that caused it. */
const stageAuthority = (state: AssemblyAuthorityState, force: boolean): void => {
if (disposed || !state.accepting) return
const reading = readingFor(
state.agent,
state,
[...state.claimed, ...assemblyMessages(state)],
)
if (!force && reading.fingerprint === state.lastFingerprint) return
state.agent.inject(reading.message)
state.lastFingerprint = reading.fingerprint
state.lastMessageId = reading.message.id
}
/**
* Capture messages claimed for the unopened step. The system-prompt
* assembly itself does not receive this batch, so the preparation listener
* preserves its request-zone provenance explicitly.
*/
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
if (disposed) return
const position = preparationPosition(agent)
if (position === undefined || position.turn !== turn) return
const existing = claimedPreparations.get(agent)
if (!samePosition(existing, position)) {
claimedPreparations.set(agent, { ...position, messages: [message] })
return
}
existing.messages.push(message)
})
/**
* Open the narrow assembly window before downstream prompt providers run.
* The initial authority enters the ordinary next-step outbox; AgentLoop
* drains its closed envelope only after pre-step accepts the step.
*/
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (disposed) return next()
const agent = context.agent
const signal = context.signal
const position = agent === undefined ? undefined : preparationPosition(agent)
if (agent === undefined || signal === undefined || position === undefined || signal.aborted) {
return next()
}
if (samePosition(assemblyAuthorities.get(agent), position)) return next()
clearAssembly(agent)
const now = Date.now()
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
const lastInjection = latestInjectionTime(agent)
if (lastInjection !== undefined
&& now >= lastInjection
&& now - lastInjection < refreshIntervalMs) return next()
}
const claimed = claimedPreparations.get(agent)
const state = {
...position,
agent,
claimed: samePosition(claimed, position) ? [...claimed.messages] : [],
deferredIds: new Set(agent.inbox.nextStep.map(message => message.id)),
handledIds: new Set<string>(),
accepting: true,
signal,
onAbort: () => {},
} satisfies AssemblyAuthorityState
state.onAbort = () => { closeAssembly(state) }
assemblyAuthorities.set(agent, state)
signal.addEventListener('abort', state.onAbort, { once: true })
try {
stageAuthority(state, true)
return await next()
} finally {
closeAssembly(state)
}
}, { prepend: true })
/** A late steering message supersedes the authority synchronously behind it. */
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (disposed) return
const state = assemblyAuthorities.get(agent)
if (state === undefined || !state.accepting
|| state.deferredIds.has(message.id)
|| !agent.inbox.nextStep.some(candidate => candidate.id === message.id)
|| message.source.kind !== 'user') return
const handledByReplacement = state.handledIds.has(message.id)
stageAuthority(state, !handledByReplacement)
state.handledIds.add(message.id)
})
/** Recompute after an edit/discard, but do not resurrect a cleared inbox. */
ctx.on('agent/inbox/discarded', ({ agent, message }) => {
if (disposed) return
const state = assemblyAuthorities.get(agent)
if (state === undefined || !state.accepting
|| state.deferredIds.has(message.id)
|| message.source.kind !== 'user') return
if (!agent.inbox.nextStep.some(candidate => isAuthorityMessage(candidate, state))) {
closeAssembly(state)
return
}
stageAuthority(state, false)
state.handledIds = new Set(
assemblyMessages(state)
.filter(candidate => candidate.source.kind === 'user')
.map(candidate => candidate.id),
)
})
ctx.on('agent/pre-step', async (
{ agent, turn, step, signal },
next,
): Promise<PreStepDecision> => {
const wasDisposed = (): boolean => disposed
const wasAborted = (): boolean => signal.aborted
if (wasDisposed()) return next()
const decision = await next()
if (wasDisposed()) return decision
const staged = assemblyAuthorities.get(agent)
if (decision.kind === 'reject' || signal.aborted) {
if (samePosition(staged, { turn, step })) closeAssembly(staged)
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|| (step === 1 && decision.messages.length === 0)) {
return decision
}
if (samePosition(staged, { turn, step })) {
closeAssembly(staged)
const reading = readingFor(agent, { turn, step }, decision.messages)
if (reading.fingerprint !== staged.lastFingerprint) {
const replaced = staged.lastMessageId === undefined
? false
: agent.inbox.replace(staged.lastMessageId, reading.message)
if (!replaced) agent.inject(reading.message)
staged.lastFingerprint = reading.fingerprint
staged.lastMessageId = reading.message.id
}
return decision
}
if (decision.messages.length === 0) return decision
const now = Date.now()
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
const lastInjection = latestInjectionTime(agent)
@@ -487,51 +251,16 @@ export function apply(ctx: Context, config: Config): () => void {
&& now >= lastInjection
&& now - lastInjection < refreshIntervalMs) return decision
}
const reading = readingFor(agent, { turn, step }, decision.messages)
return {
kind: 'enter',
messages: [
...decision.messages,
reading.message,
readingFor(agent, turn, step, decision.messages),
],
}
}, { prepend: true })
/** Step/turn/lifecycle boundaries release request-only bookkeeping. */
ctx.on('session/event', (session, event) => {
if (disposed) return
if (event.type !== 'step/start' && event.type !== 'turn/end') return
const agent = ctx.agents.get(session.id)
if (agent === undefined || agent.session !== session) return
clearAssembly(agent)
if (event.type === 'turn/end') claimedPreparations.delete(agent)
})
ctx.on('agent/status', (agent, status) => {
if (disposed) return
if (status !== 'idle') return
clearAssembly(agent)
claimedPreparations.delete(agent)
})
ctx.on('agent/disposed', (agent) => {
if (disposed) return
clearAssembly(agent)
claimedPreparations.delete(agent)
})
return () => {
disposed = true
for (const [agent, state] of assemblyAuthorities) {
closeAssembly(state)
for (const message of [...agent.inbox.nextStep]) {
if (!isAuthorityMessage(message, state)) continue
try {
agent.inbox.remove(message.id)
} catch (error: unknown) {
ctx.logger.warn(`time-context: failed to discard authority during dispose: ${String(error)}`)
}
}
clearAssembly(agent, state)
}
claimedPreparations.clear()
}
}
+21 -22
View File
@@ -3,7 +3,7 @@
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { decodeTimeContextSource, renderTimeContextAuthority } from './authority.ts'
import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
const SOURCE_NAME = 'time-context'
@@ -21,21 +21,15 @@ export const name = 'time-context-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Derive the step preparation owned by a time-context reading. A normal
* reading follows `step/start`; a pre-step failure may settle context-only
* output in the still-open turn before that boundary.
*/
/** Derive the open step owned by a time-context reading. */
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
let openTurn: number | undefined
let openStep: number | undefined
let nextStep = 1
for (const event of history) {
switch (event.type) {
case 'turn/start': {
openTurn = event.data.turn
openStep = undefined
nextStep = 1
break
}
case 'step/start': {
@@ -44,7 +38,6 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
}
case 'step/end': {
openStep = undefined
nextStep = event.data.step + 1
break
}
case 'turn/end': {
@@ -57,11 +50,19 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
}
}
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
return { turn: openTurn, step: openStep ?? nextStep }
if (openStep === undefined) fail('time-context reading must follow step/start')
return { turn: openTurn, step: openStep }
}
/** 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] : [])
}
/** 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,
@@ -81,18 +82,16 @@ function validateReading(
if (turn !== expected.turn || step !== expected.step) {
fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`)
}
let source: ReturnType<typeof decodeTimeContextSource>
try {
source = decodeTimeContextSource(event.data.source)
} catch (error: unknown) {
fail(error instanceof Error ? error.message : String(error))
}
if (source.authority.turn !== turn || source.authority.step !== step) {
fail('time-context text and source authority name different positions')
if (Object.keys(event.data.source).length !== 2) {
fail('time-context source must not duplicate request authority')
}
const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.`
if (renderedAuthority !== renderTimeContextAuthority(source.authority)) {
fail('time-context text and source authority describe different zones')
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 baseline = match[6]
if ((step === 1) !== (baseline === 'model-visible message')) {
@@ -115,7 +114,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.events.slice(0, index), event, fail)
validateReading(session, session.events.slice(0, index), event, fail)
}
}
@@ -128,7 +127,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.events, event, fail)
validateReading(session, session.events, event, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
@@ -0,0 +1,54 @@
/** Request-zone derivation shared by time-context rendering and Schedule tools. */
import type { UserMessage } from '@deepseek-ai/dsh-llm'
/** Client-zone facts derived from the user-rpc messages in one open turn. */
export type ClientTimeZoneContext =
| { readonly kind: 'resolved'; readonly timeZone: string }
| { readonly kind: 'mixed'; readonly timeZones: string[] }
| { readonly kind: 'missing' }
/** Read the Host-validated client zone from one ordinary user-rpc message. */
function clientTimeZone(message: UserMessage): string | undefined {
const source = message.source
return source.kind === 'user'
&& 'clientTimeZone' in source
&& typeof source.clientTimeZone === 'string'
? source.clientTimeZone
: 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.
*/
export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): ClientTimeZoneContext {
const timeZones = [...new Set(messages.flatMap((message) => {
const timeZone = clientTimeZone(message)
return timeZone === undefined ? [] : [timeZone]
}))].sort()
const [timeZone, ...remaining] = timeZones
if (timeZone === undefined) return { kind: 'missing' }
if (remaining.length === 0) return { kind: 'resolved', timeZone }
return { kind: 'mixed', timeZones }
}
/**
* 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.
*/
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}.`
}
@@ -22,9 +22,6 @@ function event(
content?: unknown[],
plugin = 'time-context',
): SessionEvent<'user/message'> {
const position = /turn (\d+), step (\d+):/.exec(text)
const turn = Number(position?.[1] ?? '1')
const step = Number(position?.[2] ?? '1')
return {
type: 'user/message',
seq: 0,
@@ -35,12 +32,6 @@ function event(
? {
kind: 'plugin',
plugin,
authority: {
turn,
step,
session: { kind: 'unavailable' },
client: { kind: 'missing' },
},
}
: { kind: 'plugin', plugin },
}),
@@ -52,10 +43,12 @@ function reading(
step = '1',
baseline = 'model-visible message',
timestamp = '2026-07-14T00:00:00+00:00[UTC]',
sessionTimeZone = 'unavailable',
clientTimeZone = 'missing',
): string {
return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
+ 'Session time zone: unavailable.\n'
+ 'Client time zone for this request: missing.\n'
+ `Session time zone: ${sessionTimeZone}.\n`
+ `Client time zone for this request: ${clientTimeZone}.\n`
+ `Elapsed since the preceding ${baseline}: unavailable.`
}
@@ -79,18 +72,11 @@ function preparing(turn: number, step: number): Session {
}
function appendReading(session: Session, text: string): void {
const position = /turn (\d+), step (\d+):/.exec(text)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: {
kind: 'plugin',
plugin: 'time-context',
authority: {
turn: Number(position?.[1] ?? '1'),
step: Number(position?.[2] ?? '1'),
session: { kind: 'unavailable' },
client: { kind: 'missing' },
},
},
}), { surfaceOp: 'append' })
}
@@ -112,6 +98,59 @@ describe('time-context invariants', () => {
}).not.toThrow()
})
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', clientTimeZone: 'America/New_York' } as never,
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
expect(() => {
ctx.emit('session/event', session, event(reading(
'1',
'1',
'model-visible message',
'2026-07-14T00:00:00+00:00[UTC]',
'Asia/Shanghai',
'America/New_York',
)))
}).not.toThrow()
expect(() => {
ctx.emit('session/event', session, event(reading(
'1',
'1',
'model-visible message',
'2026-07-14T00:00:00+00:00[UTC]',
'Asia/Shanghai',
'Asia/Shanghai',
)))
}).toThrow(/does not match the Session and current request zones/)
})
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 not duplicate request authority/)
})
it('validates each existing reading against its preceding durable prefix', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -160,11 +199,11 @@ describe('time-context invariants', () => {
.toThrow(/inside an open turn/)
})
it('accepts context-only settlement before step/start', async () => {
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())) }).not.toThrow()
expect(() => { ctx.emit('session/event', session, event(reading())) }).toThrow(/follow step\/start/)
})
it('rejects a reading outside its open preparation', async () => {
@@ -172,7 +211,7 @@ describe('time-context invariants', () => {
const ended = preparing(1, 1)
ended.append('step/end', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', ended, event(reading())) })
.toThrow(/expected turn 1\/step 2/)
.toThrow(/follow step\/start/)
expect(() => {
ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
}).toThrow(/inside an open turn/)
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import {
deriveClientTimeZoneContext,
renderTimeZoneContext,
} from '@deepseek-ai/dsh-time-context'
function request(clientTimeZone?: unknown) {
return createUserMessage({
content: [{ type: 'text', text: 'request' }],
source: clientTimeZone === undefined
? { kind: 'user' }
: { kind: 'user', clientTimeZone } as never,
})
}
describe('request-zone derivation', () => {
it('derives missing, one resolved zone, and sorted unique mixed zones', () => {
const plugin = createUserMessage({
content: [],
source: { kind: 'plugin', plugin: 'fixture' },
})
expect(deriveClientTimeZoneContext([plugin, request(), request(1)])).toEqual({ kind: 'missing' })
expect(deriveClientTimeZoneContext([
request('Asia/Shanghai'),
request('Asia/Shanghai'),
])).toEqual({ kind: 'resolved', timeZone: 'Asia/Shanghai' })
expect(deriveClientTimeZoneContext([
request('Asia/Shanghai'),
request('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.',
)
})
})
@@ -171,30 +171,29 @@ describe('durable step context', () => {
timeZone: 'Asia/Shanghai',
})
session.append('turn/start', { turn: 1 })
const agent = sessionAgent(session)
await fire(ctx, sessionAgent(session), 1, 1, SIGNAL, [
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',
authority: {
turn: 1,
step: 1,
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
},
},
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 () => {
@@ -215,21 +214,10 @@ describe('durable step context', () => {
rpcMessage('second tab', 'America/New_York'),
])
const reading = session.events.at(-1)
expect(reading).toMatchObject({
type: 'user/message',
data: {
source: {
authority: {
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: {
kind: 'mixed',
timeZones: ['America/New_York', 'Asia/Shanghai'],
},
},
},
},
})
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 () => {
@@ -252,12 +240,6 @@ describe('durable step context', () => {
expect(event.data.source).toEqual({
kind: 'plugin',
plugin: 'time-context',
authority: {
turn: 1,
step: 1,
session: { kind: 'unavailable' },
client: { kind: 'missing' },
},
})
expect(event.surfaceOp).toBe('append')
})
@@ -435,6 +417,20 @@ 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) {
@@ -456,13 +452,26 @@ 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', 1],
['throws', 0],
['cancels', 0],
] as const)('settles preparation context when a downstream pre-step listener %s', async (mode, expectedContexts) => {
] as const)('does not persist context when a downstream pre-step listener %s', async (mode, expectedContexts) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
ctx.on('agent/pre-step', ({ agent: subject }, next) => {
@@ -481,123 +490,42 @@ describe('real agent-loop request history', () => {
await ctx.fiber.dispose()
})
it('drains late assembly steering between initial and superseding same-step authorities', async () => {
const adapter = new ScriptedAdapter([textResponse('done')])
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<undefined>()
const release = Promise.withResolvers<undefined>()
let proposedTexts: string[] = []
let blocked = true
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (context.agent !== undefined) {
if (blocked && context.agent !== undefined) {
entered.resolve(undefined)
await release.promise
}
return next()
})
ctx.on('agent/pre-step', async ({ messages }, next) => {
proposedTexts = messages.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
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(1)
expect(adapter.requests).toHaveLength(2)
expect(agent.inbox.hasPending).toBe(false)
const enteredMessages = agent.session.events.filter(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message',
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"].',
)
const texts = enteredMessages.map(message =>
message.data.content.find(block => block.type === 'text')?.text)
expect(texts).toEqual([
'start in Shanghai',
'switch to New York',
expect.stringContaining('Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].'),
])
expect(proposedTexts).toEqual([
'start in Shanghai',
'switch to New York',
])
const authorities = enteredMessages
.filter(message => message.data.source.kind === 'plugin')
.map(message => message.data.source.kind === 'plugin' && 'authority' in message.data.source
? message.data.source.authority
: undefined)
expect(authorities).toEqual([
expect.objectContaining({
turn: 1,
step: 1,
client: {
kind: 'mixed',
timeZones: ['America/New_York', 'Asia/Shanghai'],
},
}),
])
expect(contextTexts(agent.session)).toHaveLength(2)
await ctx.fiber.dispose()
})
it('collapses edited and discarded late steering to one truthful final authority', async () => {
const adapter = new ScriptedAdapter([textResponse('done')])
const ctx = await loopHarness(adapter)
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (context.agent !== undefined) {
entered.resolve(undefined)
await release.promise
}
return next()
})
const agent = ctx.agentLoop.create(SessionId('edited-late-steering'), {
provider: 'mock',
model: 'mock',
})
agent.followup(rpcMessage('start in Shanghai', 'Asia/Shanghai'))
await entered.promise
const edited = rpcMessage('switch to New York', 'America/New_York')
agent.steer(edited)
const replacement = rpcMessage('stay in Shanghai', 'Asia/Shanghai')
expect(agent.inbox.replace(edited.id, replacement)).toBe(true)
const discarded = rpcMessage('temporary New York tab', 'America/New_York')
agent.steer(discarded)
expect(agent.inbox.remove(discarded.id)).toBe(true)
release.resolve(undefined)
await agent.whenIdle()
expect(agent.inbox.hasPending).toBe(false)
const request = requestText(adapter.requests[0]!)
expect(request).toContain('start in Shanghai')
expect(request).toContain('stay in Shanghai')
expect(request).not.toContain('switch to New York')
expect(request).not.toContain('temporary New York tab')
expect(request).not.toContain('Client time zone for this request: mixed')
const authorities = agent.session.events.filter(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context')
expect(authorities).toHaveLength(1)
expect(authorities[0]).toMatchObject({
data: {
source: {
authority: {
turn: 1,
step: 1,
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
},
},
},
})
await ctx.fiber.dispose()
})
it('does not let preparation authority create a step after downstream suppression', async () => {
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) => {
@@ -619,7 +547,7 @@ describe('real agent-loop request history', () => {
await ctx.fiber.dispose()
})
it('settles authorities but preserves steering when keep-inbox cancellation wins assembly', async () => {
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<undefined>()
@@ -644,21 +572,16 @@ describe('real agent-loop request history', () => {
await agent.whenIdle()
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
expect(contextTexts(agent.session)).toHaveLength(1)
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)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
const lastAuthority = agent.session.events.findLast(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context')
expect(lastAuthority?.seq).toBeLessThan(turnEnd?.seq ?? -1)
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()
})
@@ -694,55 +617,6 @@ describe('real agent-loop request history', () => {
await ctx.fiber.dispose()
})
it('drops a rejected context append instead of leaking its authority to the next turn', async () => {
const adapter = new ScriptedAdapter([textResponse('resumed')])
const ctx = await loopHarness(adapter)
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
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('context-append-rejection'), {
provider: 'mock',
model: 'mock',
})
const originalAppend = agent.session.append.bind(agent.session)
let rejectContext = true
vi.spyOn(agent.session, 'append').mockImplementation(((type, data, options) => {
if (rejectContext && type === 'user/message'
&& (data as UserMessage).source.kind === 'plugin'
&& (data as UserMessage).source.plugin === 'time-context') {
rejectContext = false
throw new Error('context append unavailable')
}
return originalAppend(type, data, options)
}) as typeof agent.session.append)
agent.followup(rpcMessage('start', 'Asia/Shanghai'))
await entered.promise
agent.cancel({ kind: 'user' }, { keepInbox: true })
blocked = false
release.resolve(undefined)
await agent.whenIdle()
expect(contextTexts(agent.session)).toHaveLength(0)
expect(agent.inbox.nextStep.some(message =>
message.source.kind === 'plugin' && message.source.plugin === 'time-context')).toBe(false)
agent.followup(rpcMessage('wake', 'Asia/Shanghai'))
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
const request = requestText(adapter.requests[0]!)
expect(request).toContain('Time sampled while preparing turn 2, step 1:')
expect(request).not.toContain('Time sampled while preparing turn 1, step 1:')
await ctx.fiber.dispose()
})
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)
+1 -5
View File
@@ -55,11 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message.
System-prompt assembly runs after that claim and before `agent/pre-step`. A provider may use this bounded asynchronous window to stage an authority-delimited envelope in the next-step inbox. The driver adds the envelope's ordinary messages to the pre-step proposal, so guards and transformations see late steering, but keeps preparation authorities outside that decision. Rejection leaves the claimed batch removed; an empty enter decision consumes the envelope without opening a step. A non-empty enter appends the transformed messages followed by only the envelope's final authority after `step/start`. If preparation fails before then, the driver removes the envelope and settles at most its final appendable authority inside the no-step turn, so no old authority leaks while unrelated pending input retains its normal ownership. The [durable time-context decision](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md) owns the current producer.
Input inserted after an ordinary claim remains pending unless it belongs to that bounded envelope, and idle injection waits until follow-up or steering wakes the driver.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
+4 -122
View File
@@ -51,36 +51,6 @@ type PreparedStep =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
/** The exact private time-context source shape that may span prompt assembly. */
function isPreparationAuthority(message: UserMessage, turn: number, step: number): boolean {
const source = message.source as unknown
if (typeof source !== 'object' || source === null || Array.isArray(source)) return false
const record = source as Record<string, unknown>
if (record['kind'] !== 'plugin' || record['plugin'] !== 'time-context') return false
const authority = record['authority']
return typeof authority === 'object'
&& authority !== null
&& !Array.isArray(authority)
&& (authority as Record<string, unknown>)['turn'] === turn
&& (authority as Record<string, unknown>)['step'] === step
}
/**
* Invoke the concrete driver's private Inbox range primitive without adding a
* cross-package public method or a source-only package import.
*/
function claimPreparationRange(
inbox: Inbox,
start: number,
count: number,
turn: number,
): UserMessage[] {
type DriverInbox = {
claimRange(target: InboxTarget, start: number, count: number, turn: number, publish?: boolean): UserMessage[]
}
return (inbox as unknown as DriverInbox).claimRange('next-step', start, count, turn)
}
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
@@ -261,86 +231,17 @@ export class ReactLoopAgent implements Agent {
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const proposal = context === undefined ? claimed : [...claimed, context]
const preparation = this.preparationEnvelope(position.turn, position.step)
.filter(message => !isPreparationAuthority(message, position.turn, position.step))
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: [...proposal, ...preparation], ...position, signal },
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: [...proposal, ...preparation],
messages: context === undefined ? claimed : [...claimed, context],
}),
)
signal.throwIfAborted()
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
/**
* Read the closed assembly envelope without consuming it. Non-authority
* messages enter the pre-step proposal; the final authority is resolved
* only after downstream pre-step transforms have settled.
*/
private preparationEnvelope(turn: number, step: number): UserMessage[] {
const pending = this.inbox.nextStep
const first = pending.findIndex(message => isPreparationAuthority(message, turn, step))
if (first < 0) return []
let last = first
for (let index = first + 1; index < pending.length; index += 1) {
const message = pending[index]
if (message !== undefined && isPreparationAuthority(message, turn, step)) last = index
}
return pending.slice(first, last + 1)
}
/**
* Claim the closed assembly envelope. Messages before or after its first and
* last authority retain ordinary next-step ownership.
*/
private claimPreparationEnvelope(turn: number, step: number): UserMessage[] {
const envelope = this.preparationEnvelope(turn, step)
const firstMessage = envelope[0]
if (firstMessage === undefined) return []
const first = this.inbox.nextStep.findIndex(message => message.id === firstMessage.id)
/* v8 ignore next -- preparationEnvelope returned a live next-step member. */
if (first < 0) throw new Error('preparation envelope moved before it could be claimed')
return claimPreparationRange(this.inbox, first, envelope.length, turn)
}
/**
* Close context-only assembly output inside a turn that never reached
* `step/start`. Each authority leaves the inbox before its surface append,
* so an append rejection fails closed instead of leaking it into a later
* turn. Steering and unrelated pending input are not touched.
*/
private settlePreparationAuthorities(turn: number, step: number): void {
let finalAuthority: UserMessage | undefined
for (const authority of [...this.inbox.nextStep]) {
if (!isPreparationAuthority(authority, turn, step)) continue
const index = this.inbox.nextStep.findIndex(message => message.id === authority.id)
if (index < 0) continue
let claimed: UserMessage[]
try {
claimed = claimPreparationRange(this.inbox, index, 1, turn)
} catch (error: unknown) {
this.dispatch.emit('agent/error', { turn, step, error })
this.loopCtx.logger.warn(
`agent "${this.id}": failed to remove pre-step time context: ${errorChain(error)}`,
)
continue
}
finalAuthority = claimed.at(-1) ?? finalAuthority
}
if (finalAuthority === undefined) return
try {
this.session.append('user/message', finalAuthority, { surfaceOp: 'append' })
} catch (error: unknown) {
this.dispatch.emit('agent/error', { turn, step, error })
this.loopCtx.logger.warn(
`agent "${this.id}": dropped pre-step time context after append failed: ${errorChain(error)}`,
)
}
}
/** Open one turn before claiming its first proposed step. */
private async turn(): Promise<boolean> {
if (this.phase.kind !== 'running') {
@@ -358,27 +259,19 @@ export class ReactLoopAgent implements Agent {
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
let preparingStep: number | undefined
try {
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
preparingStep = step
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
if (turnEnds && decision.messages.length === 0) {
this.claimPreparationEnvelope(turn, step)
preparingStep = undefined
break
}
if (turnEnds && decision.messages.length === 0) break
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
this.claimPreparationEnvelope(turn, step)
preparingStep = undefined
turnEnds = { kind: 'completed' }
return false
}
@@ -386,14 +279,7 @@ export class ReactLoopAgent implements Agent {
this.session.append('step/start', { turn, step })
phase.step = step
try {
const preparation = this.claimPreparationEnvelope(turn, step)
preparingStep = undefined
const finalAuthority = preparation.findLast(message =>
isPreparationAuthority(message, turn, step))
for (const message of [
...decision.messages,
...(finalAuthority === undefined ? [] : [finalAuthority]),
]) {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
// max-tokens is sticky: once any step hits the ceiling, later steps
@@ -428,10 +314,6 @@ export class ReactLoopAgent implements Agent {
}
this.throwError(error)
} finally {
if (preparingStep !== undefined) {
this.settlePreparationAuthorities(turn, preparingStep)
preparingStep = undefined
}
try {
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
this.session.append('turn/end', { turn, reason: turnEnds! })
+2 -23
View File
@@ -71,35 +71,14 @@ export class Inbox {
* @internal - The agent loop's step-boundary operation, not a plugin extension point.
*/
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.claimRange('next-step', 0, this.nextStep.length, turn, false)
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') {
claimed.push(...this.claimRange('next-turn', 0, 1, turn, false))
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
}
for (const message of claimed) this.notifications.claimed(message, turn)
return claimed
}
/**
* Remove one contiguous pending range into an open turn without classifying
* it as cancellation. Concrete drivers may use this protected primitive to
* finish a private step-boundary drain while keeping {@link Inbox}'s public
* claim semantics unchanged.
* @internal
*/
private claimRange(
target: InboxTarget,
start: number,
count: number,
turn: number,
publish = true,
): UserMessage[] {
const claimed = this.mutate(target, start, count, [], false)
if (publish) {
for (const message of claimed) this.notifications.claimed(message, turn)
}
return claimed
}
/**
* Append one message to a pending list and durably record the insertion.
* @param target - pending list to extend.
+4 -4
View File
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
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 an implicit-zone authority.
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.
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.
@@ -20,11 +20,11 @@ Replay rejects unknown versions, extra fields, reused ids, and delete or dispatc
`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 authority
## 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 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 step's final time-context authority reports one resolved client zone equal to the immutable Session zone.
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 step has a time-context reading and the original user-rpc sources in that turn 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. A headerless Session, a missing or mixed client authority, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_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.
+4 -4
View File
@@ -8,7 +8,7 @@
请在 `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 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式时区 authority 仍可使用。
若根 agent 需要在未显式指定时区时解析本地 `at` 值,请在发布该 agent 前加载 `@deepseek-ai/dsh-time-context`。官方 Schedule Web overlay 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式请求时区上下文仍可使用。
每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。
@@ -20,11 +20,11 @@
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt 和 occurrenceclient renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 idpresentation 绝不会改变 live ownership。
## 绝对时间 authority
## 绝对时间上下文
`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 时区;仅当当前步骤最终的 time-context authority 给出唯一一个已解析的客户端时区,且该时区与不可变 Session 时区相时,才可以省略 `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 step 含有 time-context 读数,并且该 turn 的原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`
Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。如果 Session 没有 header、客户端 authority 缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`
Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。Schedule 会直接从这些原始拥有方派生,而不会把它们复制进 time-context source。如果 Session 没有 header、客户端时区结果缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`
落在夏令时空档内的本地时间会被拒绝。遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC 目标,Schedule 的任何路径都不会读取进程时区。
+31 -40
View File
@@ -6,8 +6,7 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { decodeTimeContextSource } from '@deepseek-ai/dsh-time-context'
import type { TimeContextAuthority } from '@deepseek-ai/dsh-time-context'
import { deriveClientTimeZoneContext } from '@deepseek-ai/dsh-time-context'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import {
@@ -218,58 +217,47 @@ interface AtTimeZoneContext {
readonly clientTimeZones: string[]
}
/** Find the last time-context authority belonging to the currently open step. */
function currentTimeContextAuthority(agent: Agent): TimeContextAuthority | undefined {
/** Derive request zones only while the current open step contains a time-context reading. */
function currentClientTimeZoneContext(agent: Agent): ReturnType<typeof deriveClientTimeZoneContext> | undefined {
const events = agent.session.events
let start = -1
let stepStart = -1
let turn = 0
let step = 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') return undefined
if (event.type === 'step/end' || event.type === 'turn/end') return undefined
if (event.type === 'step/start') {
start = index
stepStart = index
turn = event.data.turn
step = event.data.step
break
}
}
if (start < 0) return undefined
for (let index = events.length - 1; index > start; index--) {
const event = events[index]
/* v8 ignore next -- the loop bounds index to the dense Session event array. */
if (event === undefined || event.type !== 'user/message') continue
const source = event.data.source
if (source.kind !== 'plugin' || source.plugin !== 'time-context') continue
let decoded: ReturnType<typeof decodeTimeContextSource>
try {
decoded = decodeTimeContextSource(source)
} catch {
return undefined
}
if (decoded.authority.turn === turn && decoded.authority.step === step) {
return decoded.authority
}
}
return undefined
if (stepStart < 0) return undefined
const hasReading = events.slice(stepStart + 1).some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context'
&& Object.keys(event.data.source).length === 2)
if (!hasReading) return undefined
const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
if (turnStart < 0) return undefined
const messages = events.slice(turnStart + 1)
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
return deriveClientTimeZoneContext(messages)
}
/** Resolve the only authority state that may supply an omitted local time zone. */
/** 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 authority = currentTimeContextAuthority(agent)
const clientTimeZones = authority === undefined || authority.client.kind === 'missing'
const client = currentClientTimeZoneContext(agent)
const clientTimeZones = client === undefined || client.kind === 'missing'
? []
: authority.client.kind === 'resolved'
? [authority.client.timeZone]
: [...authority.client.timeZones]
: client.kind === 'resolved'
? [client.timeZone]
: [...client.timeZones]
const implicitTimeZone = sessionTimeZone !== 'unavailable'
&& authority?.session.kind === 'resolved'
&& authority.session.timeZone === sessionTimeZone
&& authority.client.kind === 'resolved'
&& authority.client.timeZone === sessionTimeZone
&& client?.kind === 'resolved'
&& client.timeZone === sessionTimeZone
? sessionTimeZone
: undefined
return {
@@ -279,14 +267,17 @@ function atTimeZoneContext(agent: Agent): AtTimeZoneContext {
}
}
/** Translate a contained input failure to the closed tool union. */
/** 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: timeZone?.sessionTimeZone ?? 'unavailable',
clientTimeZones: timeZone?.clientTimeZones ?? [],
sessionTimeZone: requestTimeZone.sessionTimeZone,
clientTimeZones: requestTimeZone.clientTimeZones,
}
}
return { code: error.code, message: error.message }
@@ -91,21 +91,16 @@ function value(result: ToolExecutionResult): unknown {
return result.value
}
function appendTimeAuthority(
agent: Agent,
authority: {
turn: number
step: number
session: { kind: 'resolved'; timeZone: string } | { kind: 'unavailable' }
client:
| { kind: 'resolved'; timeZone: string }
| { kind: 'mixed'; timeZones: string[] }
| { kind: 'missing' }
},
): void {
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', clientTimeZone } as never,
}), { surfaceOp: 'append' })
}
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'time authority' }],
source: { kind: 'plugin', plugin: 'time-context', authority },
content: [{ type: 'text', text: 'time context' }],
source: { kind: 'plugin', plugin: 'time-context' },
}), { surfaceOp: 'append' })
}
@@ -254,7 +249,7 @@ describe('Schedule tool protocol', () => {
expect(changes[0]?.data).not.toHaveProperty('time_zone')
})
it('fails closed when local at lacks confirmed request-zone authority', async () => {
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' },
@@ -268,16 +263,11 @@ describe('Schedule tool protocol', () => {
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
})
it('uses only the current-step matching zone authority for implicit local at', async () => {
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 })
appendTimeAuthority(test.agent, {
turn: 1,
step: 1,
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
})
appendRequestContext(test.agent, ['Asia/Shanghai'])
expect(value(await execute(test, 'schedule_create', {
prompt: 'implicit local', at: { date: '2026-08-06', time: '09:00:00' },
@@ -291,12 +281,7 @@ describe('Schedule tool protocol', () => {
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 })
appendTimeAuthority(mismatch.agent, {
turn: 1,
step: 1,
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: { kind: 'resolved', timeZone: 'America/New_York' },
})
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({
@@ -309,18 +294,7 @@ describe('Schedule tool protocol', () => {
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 })
appendTimeAuthority(mixed.agent, {
turn: 1,
step: 1,
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
})
appendTimeAuthority(mixed.agent, {
turn: 1,
step: 1,
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: { kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'] },
})
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({
@@ -331,12 +305,7 @@ describe('Schedule tool protocol', () => {
const unavailable = await harness()
unavailable.agent.session.append('turn/start', { turn: 1 })
unavailable.agent.session.append('step/start', { turn: 1, step: 1 })
appendTimeAuthority(unavailable.agent, {
turn: 1,
step: 1,
session: { kind: 'unavailable' },
client: { kind: 'resolved', timeZone: 'America/New_York' },
})
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({
@@ -345,16 +314,11 @@ describe('Schedule tool protocol', () => {
})
})
it('ignores prior-step authority and fails closed on a malformed current authority', async () => {
it('requires a simple current-step marker and fails closed on 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 })
appendTimeAuthority(test.agent, {
turn: 1,
step: 1,
session: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
client: { kind: 'resolved', timeZone: 'Asia/Shanghai' },
})
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({
@@ -374,6 +338,41 @@ describe('Schedule tool protocol', () => {
})
})
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', {
@@ -1007,10 +1007,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
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)`)
}
// A stored headerless session is the one compatibility case: it remains
// headerless even if a current caller supplied a zone for the live object.
if (meta.timeZone !== undefined && meta.timeZone !== session.header.timeZone) {
throw new Error(`session "${session.header.id}" is already persisted with a different timeZone (persisted: ${meta.timeZone}, live: ${String(session.header.timeZone)}) (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)`)
}
}
@@ -937,7 +937,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('stored-prefix adoption keeps a headerless record headerless for a zoned live session', async () => {
it('stored-prefix adoption rejects a zoned live session for a headerless record', async () => {
const fix = await makeFixture()
const log = [
...oneTurnLog(),
@@ -962,8 +962,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
})
const second = await fix.mount(ctx)
try {
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/)
} finally {
await second.dispose()
await ctx.fiber.dispose()
@@ -1167,7 +1166,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('a zoned live session claims headerless ownerless state without backfilling it', async () => {
it('a zoned live session cannot claim headerless ownerless state', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
@@ -1177,8 +1176,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
})
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()