mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(schedule): make absolute times explicit
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/time-context/README.md
|
||||
README.md: 9956918c63b49de8ec5e739bc3d9887e269930a8
|
||||
README.zh.md: 3a9bb1012fc0639d9c3f6b104cea5a64d4b187d6
|
||||
README.md: 0bdb0d463362427d6a7050c2d7d6d55f96779f9c
|
||||
README.zh.md: 92eb0b3f43162279ac7e0f728e685863d75a28b6
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Opt-in durable context with the current zoned time, immutable Session zone, request-bound browser zones, and elapsed time sampled during model-request preparation. Default compositions do not mount it; the opt-in Schedule Web overlay does. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
Opt-in durable context with the current zoned time, the browser zone attached to the open request, and elapsed time sampled during model-request preparation. Default compositions leave it disabled; the Schedule Web overlay mounts it so the model can interpret otherwise-unqualified dates and times in the user's browser zone. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -10,31 +10,31 @@ Opt-in durable context with the current zoned time, immutable Session zone, requ
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
config:
|
||||
timeZone: Asia/Shanghai # optional fallback for headerless Sessions; omit for the process zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every non-empty entered request batch
|
||||
timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
||||
```
|
||||
|
||||
When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats its readings. A headerless Session instead uses the configured fallback; when `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the fallback. An explicit `timeZone` is validated at plugin load but does not override a Session-owned zone.
|
||||
When the open turn contains one Host-validated browser zone, that request-local zone formats the timestamp. With missing or mixed browser provenance, `timeZone` supplies the display fallback; omitting it resolves the Node process zone once at plugin load. Node honors `TZ`, and every explicit fallback is validated through `Intl.DateTimeFormat`.
|
||||
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every non-empty entered request batch whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the Session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds elapsed since the latest injection.
|
||||
|
||||
## Request-zone ownership
|
||||
|
||||
The browser samples `Intl.DateTimeFormat().resolvedOptions().timeZone` for each prompt. The Host validates and canonicalizes that value before binding it to the exact durable `user-rpc` message source. Time-context examines only those sources in the open turn: one unique zone resolves the request, multiple zones are `mixed`, and none are `unavailable`. It does not read or mutate Session headers, connection state, or Schedule records.
|
||||
|
||||
The resolved instruction tells the model to interpret otherwise-unqualified dates and times in that browser zone. Mixed or unavailable provenance tells the model to ask the user to clarify. This is natural-language context, not an input default at another package boundary: a tool that accepts local calendar fields still owns its explicit zone requirement.
|
||||
|
||||
## Timing semantics
|
||||
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a non-empty message batch, time-context derives client zones from those final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When an injection is due and the downstream decision enters, it appends one sourced `UserMessage` to the returned batch. AgentLoop records the final batch after `step/start` and before request derivation. Rejection, listener failure, or an already-aborted signal records nothing.
|
||||
|
||||
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the exact snapshot marker `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same rendered text> }] }`; the invariant companion and Schedule consumer both fail closed if that shape or text equality drifts. The Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
|
||||
Each reading uses the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same text> }] }`. The `./invariant` companion validates that shape, re-derives the current-turn browser policy from the original `user-rpc` messages, and checks the timestamp zone and elapsed baseline.
|
||||
|
||||
Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path.
|
||||
Positive-interval scheduling scans raw durable Session events for the latest plugin-attributed message, including a reading shadowed by compaction. It therefore survives resume without a process-local cache. A positive interval can intentionally let a later request reuse existing history without a fresh reading; the Schedule Web overlay omits the interval.
|
||||
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
|
||||
Step 1 measures from the latest preceding durable user, assistant, or tool-result message. The prompt proposed for that step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Missing baselines report `unavailable`, and backward wall-clock movement clamps elapsed time to zero.
|
||||
|
||||
Step 1 measures from the latest durable model-visible message before the current proposal; the prompt entering that same step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
|
||||
|
||||
A time reading records an entered request step, not a completed or successfully transmitted request. A later request-preparation failure can therefore leave the reading in history, while a failure before `step/start` cannot.
|
||||
|
||||
The separately published `./invariant` companion checks the simple plugin source, open turn and step, elapsed baseline, and durable event time. It also re-derives Session and client zones from the Session header and current turn's original user-rpc messages, so duplicated source authority or mismatched rendered policy fails. The rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while an empty continuation or interval suppression can let a request reuse existing history without adding one.
|
||||
A reading records an entered step, not a completed or transmitted request. A later preparation failure can leave it in history. The message remains in derived conversation history until compaction shadows it; `request/header` contains no time-context state, and request reconstruction uses the complete durable surface prefix after each `step/start`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -42,14 +42,13 @@ The time reading stays in derived conversation history until a later compaction
|
||||
|
||||
#### What the model sees
|
||||
|
||||
On each non-empty entered batch that injects, one source-tagged context message contains the four lines below. `<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`. An empty continuation or positive interval can let an entered step reuse prior history without a new reading.
|
||||
Each injected message contains three lines. `<timestamp>` is an ISO-shaped timestamp with numeric offset and IANA zone; durations use compact whole-second units.
|
||||
|
||||
##### First step
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Session time zone: <iana-zone-or-unavailable>.
|
||||
Client time zone for this request: <iana-zone-or-mixed-set-or-missing>.
|
||||
Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
@@ -57,14 +56,13 @@ Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Session time zone: <iana-zone-or-unavailable>.
|
||||
Client time zone for this request: <iana-zone-or-mixed-set-or-missing>.
|
||||
Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
|
||||
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 non-empty entered request batch.
|
||||
Each reading accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one at every eligible preparation attempt.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -72,8 +70,8 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Prompt provenance only** — browser-zone context guides natural-language interpretation but does not silently supply another tool's required zone field.
|
||||
- **Mixed turns ask** — if one open turn contains prompts from different browser zones, the model is told to clarify rather than guess which one owns an unqualified time.
|
||||
- **Fallback is not user authority** — the configured or process zone formats the clock when browser provenance is missing or mixed, but the model-facing policy still says to clarify.
|
||||
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
|
||||
- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp.
|
||||
- **Headerless fallback zone** — a Session without `SessionHeader.timeZone` renders through the configured or process fallback but reports its Session zone as `unavailable`; consumers that require unambiguous local-time interpretation must request an explicit zone.
|
||||
- **Immutable Session zone** — a Session zone does not change when another browser resumes it. The request-bound browser sources expose disagreement instead of silently changing the displayed default.
|
||||
- **History cost between compactions** — omission or `0` retains one reading for every non-empty entered request batch, including batches whose later request preparation fails; empty continuations reuse prior history, while a positive interval reduces but does not eliminate this cost.
|
||||
- **History cost between compactions** — omission or `0` retains one reading for every eligible attempt; a positive interval reduces but does not eliminate this cost and may leave a later request without fresh browser-zone guidance.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。
|
||||
可选的持久上下文,包含当前带时区时间、附加到当前开放请求的浏览器时区,以及在模型请求准备期间采样的经过时长。默认组合不启用它;Schedule Web overlay 会挂载它,使模型可以按用户的浏览器时区解释未明确限定时区的日期和时间。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -10,27 +10,31 @@
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
config:
|
||||
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
|
||||
timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
||||
```
|
||||
|
||||
省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。
|
||||
当当前开放轮次只包含一个经 Host 校验的浏览器时区时,使用该请求本地时区格式化时间戳。浏览器来源信息缺失或混杂时,`timeZone` 提供显示回退;省略它则会在插件加载时解析一次 Node 进程时区。Node 遵循 `TZ`,每个显式回退值都经 `Intl.DateTimeFormat` 校验。
|
||||
|
||||
`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每次信号尚未中止且会进入步骤的合格步骤前处理添加上下文。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。
|
||||
`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每个信号尚未中止且将进入步骤的合格 pre-step 添加上下文。正数值只会在会话没有更早的 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。
|
||||
|
||||
## 请求时区归属
|
||||
|
||||
浏览器会为每条提示词采样 `Intl.DateTimeFormat().resolvedOptions().timeZone`。Host 校验并规范化该值,再将其绑定到确切的持久 `user-rpc` 消息来源。Time-context 只检查当前开放轮次中的这些来源:唯一一个时区可解析请求,多个时区记为 `mixed`,没有时区则记为 `unavailable`。它不会读取或修改会话标头、连接状态或 Schedule 记录。
|
||||
|
||||
解析后的指令告诉模型,把未明确限定时区的日期和时间解释为该浏览器时区。来源信息为 mixed 或 unavailable 时,模型会收到要求用户澄清的指令。这是自然语言上下文,并非另一个包边界上的输入默认值:接受本地日历字段的工具仍自行负责其显式时区要求。
|
||||
|
||||
## 时序语义
|
||||
|
||||
该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次中添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后、普通自动压缩(compaction)之前记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制、拒绝或失败的步骤前处理不会记录任何内容。
|
||||
该插件会前置一个 `agent/pre-step` 监听器,并先行委托下游。需要注入且下游决策进入步骤时,它会向返回批次追加一条带来源的 `UserMessage`。AgentLoop 在 `step/start` 之后、请求派生之前记录最终批次。决策被拒绝、监听器失败或信号已经中止时,不会记录任何内容。
|
||||
|
||||
正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的时间读数。因此,调度可以跨轮次以及进程恢复持续生效,不需要进程本地缓存状态。它会降低追加频率与历史增长,但绝不移除现有时间读数,且每个会话独立调度。
|
||||
每个读数都使用确切的快照来源 `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same text> }] }`。`./invariant` 配套模块会校验该形状,根据原始 `user-rpc` 消息重新派生当前轮次的浏览器策略,并检查时间戳时区与经过时长基线。
|
||||
|
||||
第 1 步从前一条模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;挂钟时间倒退时,经过时长限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次时间读数,则报告 `unavailable`。
|
||||
正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息,其中包括已被压缩(compaction)遮蔽的读数。因此,它无需进程本地缓存也能在恢复后继续生效。正数间隔可以有意让后续请求复用现有历史,而不添加新读数;Schedule Web overlay 会省略该间隔。
|
||||
|
||||
时间读数记录的是一个已进入步骤的步骤前批次,不是已完成步骤或已传输请求。后续请求准备失败时,该读数可能已留在历史中;但下游步骤前监听器拒绝或失败时,该读数不会被记录。
|
||||
第 1 步从最新一条在其之前持久化的用户、助手或工具结果消息起测量。为该步骤拟议的提示词尚未追加。后续步骤从同一轮次中前一个 time-context 事件起测量。缺少基线时报告 `unavailable`,挂钟时间倒退时将经过时长限制为零。
|
||||
|
||||
单独发布的 `./invariant` 配套模块会根据当前未结束的轮次、下一个步骤前位置、经过时长基线与持久事件时间检查每个归因于插件的时间读数。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使时间读数失效。
|
||||
|
||||
时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 之后使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:请求准备可能在进入步骤后失败,而间隔抑制可让请求复用现有历史,无需添加时间读数。
|
||||
读数记录的是已进入的步骤,不是已完成或已传输的请求。后续准备失败时,该读数可能留在历史中。消息会保留在派生会话历史中,直到压缩将其遮蔽;`request/header` 不含 time-context 状态,请求重建会使用每个 `step/start` 之后的完整持久表层前缀。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -38,12 +42,13 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每次执行注入的准备尝试都会生成一条带源标记的上下文消息,包含下方两行。`<timestamp>` 是带数字偏移与 IANA 时区、形如 ISO 的本地时间戳;持续时间使用紧凑的整秒单位。正间隔可能使某次步骤尝试没有新时间读数。
|
||||
每条注入消息包含三行。`<timestamp>` 是带数字偏移和 IANA 时区、形如 ISO 的时间戳;持续时间使用紧凑的整秒单位。
|
||||
|
||||
##### 第一步
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
@@ -51,12 +56,13 @@ Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每条注入的两行消息都会累积,直到压缩遮蔽它。正间隔会减少添加;省略或设为 `0` 则会为每次合格准备尝试添加一条。
|
||||
每个读数都会累积,直到压缩将其遮蔽。正数间隔会减少新增读数;省略或设为 `0` 时,每次合格的准备尝试都会添加一条。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -64,7 +70,8 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅限提示词来源信息**:浏览器时区上下文用于指导自然语言解释,但不会悄然填入另一工具所要求的时区字段。
|
||||
- **混合轮次会询问**:如果同一个开放轮次包含来自不同浏览器时区的提示词,模型会收到要求澄清的指令,而不会猜测哪个时区拥有未限定的时间。
|
||||
- **回退值不代表用户权威**:浏览器来源信息缺失或混杂时,配置或进程时区用于格式化时钟,但面向模型的策略仍要求澄清。
|
||||
- **整秒显示**:时间戳与持续时间省略亚秒精度,尽管持久事件时间保留毫秒。
|
||||
- **会话事件基线**:经过时长从持久追加时间戳起计算,而非客户端传输的原始发送时间戳。
|
||||
- **进程本地默认时区**:省略设置时,使用插件加载时捕获的 Node 进程 `TZ`、宿主或容器时区,而非远程用户的时区;两者不同时,请配置显式 IANA 时区。
|
||||
- **压缩之间的历史成本**:省略设置或设为 `0` 会为每次合格准备尝试保留一条时间读数,包括后续取消或失败的尝试;正间隔可以降低但无法消除该成本。
|
||||
- **压缩之间的历史成本**:省略或设为 `0` 时,每次合格尝试都会保留一条读数;正数间隔可以降低但无法消除该成本,也可能使后续请求缺少新鲜的浏览器时区指导。
|
||||
|
||||
@@ -11,14 +11,11 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
deriveClientTimeZoneContext,
|
||||
renderTimeZoneContext,
|
||||
deriveBrowserTimeZoneContext,
|
||||
renderBrowserTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
|
||||
|
||||
export type { ClientTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext } from './request-zone.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
|
||||
@@ -27,7 +24,7 @@ export const inject = ['agents']
|
||||
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** Fallback display zone for headerless Sessions. Omit to use the process zone. */
|
||||
/** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */
|
||||
timeZone?: string
|
||||
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
|
||||
refreshIntervalMs?: number
|
||||
@@ -56,7 +53,7 @@ function formatDuration(elapsedMs: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** Find the latest model-visible event before the current proposal. */
|
||||
/** Find the latest model-visible event, excluding this plugin's pending append. */
|
||||
function precedingMessageTime(agent: Agent): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
switch (event.type) {
|
||||
@@ -97,33 +94,32 @@ function latestInjectionTime(agent: Agent): number | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Collect already-entered and proposed messages belonging to one open turn. */
|
||||
/** Collect already-entered and proposed user messages belonging to one open turn. */
|
||||
function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
|
||||
const start = agent.session.events.findLastIndex(
|
||||
event => event.type === 'turn/start' && event.data.turn === turn,
|
||||
)
|
||||
const entered = start < 0
|
||||
? []
|
||||
: agent.session.events.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
: agent.session.events.slice(start + 1)
|
||||
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return [...entered, ...proposed]
|
||||
}
|
||||
|
||||
/** Render one durable time reading. */
|
||||
function renderText(
|
||||
now: number,
|
||||
turn: number,
|
||||
step: number,
|
||||
previous: number | undefined,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
displayTimeZone: string,
|
||||
sessionTimeZone: string | undefined,
|
||||
timeZone: string,
|
||||
messages: readonly UserMessage[],
|
||||
): string {
|
||||
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
|
||||
const baseline = step === 1 ? 'model-visible message' : 'step context'
|
||||
const client = deriveClientTimeZoneContext(messages)
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, displayTimeZone)}\n`
|
||||
+ `${renderTimeZoneContext(sessionTimeZone, client)}\n`
|
||||
const browserContext = renderBrowserTimeZoneContext(deriveBrowserTimeZoneContext(messages))
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
|
||||
+ `${browserContext}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
|
||||
}
|
||||
|
||||
@@ -141,12 +137,11 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
|
||||
/**
|
||||
* Register a prepended pre-step listener for the lifetime of `ctx`.
|
||||
* @param ctx - Plugin context; the listener is disposed with it.
|
||||
* @param config - Time zone and durable refresh scheduling configuration.
|
||||
* @returns A disposer that prevents an in-flight listener from contributing.
|
||||
* @throws When the refresh interval or configured/process time zone is invalid.
|
||||
* @param ctx - plugin context; the listener is disposed with it.
|
||||
* @param config - time zone and durable refresh scheduling configuration.
|
||||
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): () => void {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const timeZone = config.timeZone
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
@@ -161,66 +156,22 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
}
|
||||
const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>([[fallbackTimeZone, fallbackFormatter]])
|
||||
let disposed = false
|
||||
|
||||
/** Resolve one Session-owned formatter without making the process zone authoritative. */
|
||||
/** Resolve and cache one request-local timestamp formatter. */
|
||||
const formatterFor = (selectedTimeZone: string): Intl.DateTimeFormat => {
|
||||
const existing = formatters.get(selectedTimeZone)
|
||||
if (existing !== undefined) return existing
|
||||
let created: Intl.DateTimeFormat
|
||||
try {
|
||||
created = createTimestampFormatter(selectedTimeZone)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error })
|
||||
}
|
||||
const created = createTimestampFormatter(selectedTimeZone)
|
||||
formatters.set(selectedTimeZone, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** Build one current reading after downstream pre-step transforms settle. */
|
||||
const readingFor = (
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
messages: readonly UserMessage[],
|
||||
): UserMessage => {
|
||||
const now = Date.now()
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
const sessionTimeZone = agent.session.header.timeZone
|
||||
const displayTimeZone = sessionTimeZone ?? fallbackTimeZone
|
||||
const formatter = sessionTimeZone === undefined
|
||||
? fallbackFormatter
|
||||
: formatterFor(sessionTimeZone)
|
||||
const text = renderText(
|
||||
now,
|
||||
turn,
|
||||
step,
|
||||
previous,
|
||||
formatter,
|
||||
displayTimeZone,
|
||||
sessionTimeZone,
|
||||
requestMessages(agent, turn, messages),
|
||||
)
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
||||
})
|
||||
}
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
{ agent, turn, step, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const wasDisposed = (): boolean => disposed
|
||||
const wasAborted = (): boolean => signal.aborted
|
||||
if (wasDisposed()) return next()
|
||||
const decision = await next()
|
||||
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|
||||
|| decision.messages.length === 0) {
|
||||
return decision
|
||||
}
|
||||
if (decision.kind === 'reject' || signal.aborted) return decision
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
@@ -228,16 +179,30 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return decision
|
||||
}
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
const messages = requestMessages(agent, turn, decision.messages)
|
||||
const browser = deriveBrowserTimeZoneContext(messages)
|
||||
const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone
|
||||
const text = renderText(
|
||||
now,
|
||||
turn,
|
||||
step,
|
||||
previous,
|
||||
formatterFor(selectedTimeZone),
|
||||
selectedTimeZone,
|
||||
messages,
|
||||
)
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: [
|
||||
...decision.messages,
|
||||
readingFor(agent, turn, step, decision.messages),
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
||||
}),
|
||||
],
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
import {
|
||||
deriveBrowserTimeZoneContext,
|
||||
renderBrowserTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
|
||||
@@ -11,8 +14,7 @@ const SOURCE_NAME = 'time-context'
|
||||
const READING = new RegExp(
|
||||
'^Time sampled while preparing turn (\\d+), step (\\d+): '
|
||||
+ '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n'
|
||||
+ 'Session time zone: ([^.]+)\\.\\n'
|
||||
+ 'Client time zone for this request: (.+)\\.\\n'
|
||||
+ '(Browser time zone for this request: .+)\\n'
|
||||
+ 'Elapsed since the preceding (model-visible message|step context): '
|
||||
+ '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$',
|
||||
)
|
||||
@@ -22,7 +24,7 @@ export const name = 'time-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Derive the open step owned by a time-context reading. */
|
||||
/** Derive the open step boundary at which a time-context reading may append. */
|
||||
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
|
||||
let openTurn: number | undefined
|
||||
let openStep: number | undefined
|
||||
@@ -68,12 +70,12 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
|
||||
/** Collect the entered user messages belonging to one open turn. */
|
||||
function requestMessages(history: readonly SessionEvent[], turn: number) {
|
||||
const start = history.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
|
||||
return history.slice(start + 1).flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return history.slice(start + 1)
|
||||
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
}
|
||||
|
||||
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
||||
function validateReading(
|
||||
session: Session,
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'user/message'>,
|
||||
fail: InvariantFailure,
|
||||
@@ -121,15 +123,13 @@ function validateReading(
|
||||
|| section.text !== blockText) {
|
||||
fail('time-context source must carry only the exact snapshot text, not request authority')
|
||||
}
|
||||
const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.`
|
||||
const expectedAuthority = renderTimeZoneContext(
|
||||
session.header.timeZone,
|
||||
deriveClientTimeZoneContext(requestMessages(history, turn)),
|
||||
)
|
||||
if (renderedAuthority !== expectedAuthority) {
|
||||
fail('time-context text does not match the Session and current request zones')
|
||||
const renderedBrowserContext = match[4]
|
||||
const browserContext = deriveBrowserTimeZoneContext(requestMessages(history, turn))
|
||||
const expectedBrowserContext = renderBrowserTimeZoneContext(browserContext)
|
||||
if (renderedBrowserContext !== expectedBrowserContext) {
|
||||
fail('time-context browser-zone text does not match current-turn user messages')
|
||||
}
|
||||
const baseline = match[6]
|
||||
const baseline = match[5]
|
||||
if ((step === 1) !== (baseline === 'model-visible message')) {
|
||||
fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`)
|
||||
}
|
||||
@@ -141,20 +141,19 @@ function validateReading(
|
||||
|| event.time < renderedTime) {
|
||||
fail('time-context rendered timestamp must parse and not postdate its durable event')
|
||||
}
|
||||
const sessionTimeZone = session.header.timeZone
|
||||
if (sessionTimeZone !== undefined) {
|
||||
if (browserContext.kind === 'resolved') {
|
||||
let expectedTimestamp: string
|
||||
try {
|
||||
expectedTimestamp = formatTimestamp(
|
||||
renderedTime,
|
||||
createTimestampFormatter(sessionTimeZone),
|
||||
sessionTimeZone,
|
||||
createTimestampFormatter(browserContext.timeZone),
|
||||
browserContext.timeZone,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
fail(`time-context Session time zone cannot format its durable timestamp: ${String(error)}`)
|
||||
fail(`time-context browser zone cannot format its durable timestamp: ${String(error)}`)
|
||||
}
|
||||
if (rendered !== expectedTimestamp) {
|
||||
fail('time-context rendered timestamp does not match the Session time zone')
|
||||
fail('time-context rendered timestamp does not match the unique browser zone')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +165,7 @@ function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) continue
|
||||
validateReading(session, session.events.slice(0, index), event, fail)
|
||||
validateReading(session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) return
|
||||
validateReading(session, session.events, event, fail)
|
||||
validateReading(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/** Request-zone derivation shared by time-context rendering and Schedule tools. */
|
||||
/** Browser-zone derivation and model-facing policy text for one open request turn. */
|
||||
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Client-zone facts derived from the user-rpc messages in one open turn. */
|
||||
export type ClientTimeZoneContext =
|
||||
/** Browser-zone facts derived from user-rpc messages in one open turn. */
|
||||
export type BrowserTimeZoneContext =
|
||||
| { readonly kind: 'resolved'; readonly timeZone: string }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: string[] }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: readonly string[] }
|
||||
| { readonly kind: 'missing' }
|
||||
|
||||
/** Read the Host-validated client zone from one ordinary user-rpc message. */
|
||||
function clientTimeZone(message: UserMessage): string | undefined {
|
||||
/** Read a Host-validated browser zone from one ordinary user-rpc message. */
|
||||
function browserTimeZone(message: UserMessage): string | undefined {
|
||||
const source = message.source
|
||||
return source.kind === 'user'
|
||||
&& 'rpcId' in source
|
||||
@@ -21,13 +22,15 @@ function clientTimeZone(message: UserMessage): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the unique, mixed, or missing client zone from entered request input.
|
||||
* @param messages - User messages belonging to the current open turn.
|
||||
* @returns A sorted, duplicate-free request-zone context.
|
||||
* Derive the unique, mixed, or missing browser zone for one open turn.
|
||||
* @param messages - Entered and proposed user messages belonging to the turn.
|
||||
* @returns Sorted, duplicate-free browser-zone facts.
|
||||
*/
|
||||
export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): ClientTimeZoneContext {
|
||||
export function deriveBrowserTimeZoneContext(
|
||||
messages: readonly UserMessage[],
|
||||
): BrowserTimeZoneContext {
|
||||
const timeZones = [...new Set(messages.flatMap((message) => {
|
||||
const timeZone = clientTimeZone(message)
|
||||
const timeZone = browserTimeZone(message)
|
||||
return timeZone === undefined ? [] : [timeZone]
|
||||
}))].sort()
|
||||
const [timeZone, ...remaining] = timeZones
|
||||
@@ -37,20 +40,23 @@ export function deriveClientTimeZoneContext(messages: readonly UserMessage[]): C
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Session and request-zone facts for the model-visible time reading.
|
||||
* @param sessionTimeZone - Immutable Session zone, or `undefined` for legacy Sessions.
|
||||
* @param client - Client zones derived from the current open turn.
|
||||
* @returns The two policy lines appended to a time-context reading.
|
||||
* Render the model instruction for one browser-zone context.
|
||||
* @param context - Browser-zone facts for the open turn.
|
||||
* @returns One durable policy line.
|
||||
*/
|
||||
export function renderTimeZoneContext(
|
||||
sessionTimeZone: string | undefined,
|
||||
client: ClientTimeZoneContext,
|
||||
): string {
|
||||
const session = sessionTimeZone ?? 'unavailable'
|
||||
const request = client.kind === 'resolved'
|
||||
? client.timeZone
|
||||
: client.kind === 'mixed'
|
||||
? `mixed ${JSON.stringify(client.timeZones)}`
|
||||
: 'missing'
|
||||
return `Session time zone: ${session}.\nClient time zone for this request: ${request}.`
|
||||
export function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string {
|
||||
switch (context.kind) {
|
||||
case 'resolved':
|
||||
return `Browser time zone for this request: ${context.timeZone}. `
|
||||
+ 'Interpret otherwise-unqualified dates and times in this zone.'
|
||||
case 'mixed':
|
||||
return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. `
|
||||
+ 'Ask the user to clarify otherwise-unqualified dates and times.'
|
||||
case 'missing':
|
||||
return 'Browser time zone for this request: unavailable. '
|
||||
+ 'Ask the user to clarify otherwise-unqualified dates and times.'
|
||||
/* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */
|
||||
default:
|
||||
return assertNever(context, 'BrowserTimeZoneContext')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,16 +45,14 @@ function reading(
|
||||
step = '1',
|
||||
baseline = 'model-visible message',
|
||||
timestamp = '2026-07-14T00:00:00+00:00[UTC]',
|
||||
sessionTimeZone = 'unavailable',
|
||||
clientTimeZone = 'missing',
|
||||
browser = 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.',
|
||||
): string {
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
|
||||
+ `Session time zone: ${sessionTimeZone}.\n`
|
||||
+ `Client time zone for this request: ${clientTimeZone}.\n`
|
||||
+ `${browser}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: unavailable.`
|
||||
}
|
||||
|
||||
function preparing(turn: number, step: number): Session {
|
||||
function preparing(turn: number, step: number, clientTimeZone?: string): Session {
|
||||
const session = Session.create(SessionId(`time-invariant-${turn}-${step}`))
|
||||
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
|
||||
session.append('turn/start', { turn: priorTurn })
|
||||
@@ -63,7 +61,9 @@ function preparing(turn: number, step: number): Session {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
source: clientTimeZone === undefined
|
||||
? { kind: 'user' }
|
||||
: { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
for (let priorStep = 1; priorStep < step; priorStep += 1) {
|
||||
session.append('step/start', { turn, step: priorStep })
|
||||
@@ -89,8 +89,7 @@ describe('time-context invariants', () => {
|
||||
it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
|
||||
const ctx = await setup()
|
||||
const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n'
|
||||
+ 'Session time zone: unavailable.\n'
|
||||
+ 'Client time zone for this request: missing.\n'
|
||||
+ 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\n'
|
||||
+ 'Elapsed since the preceding step context: 4m 2s.'
|
||||
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
|
||||
})
|
||||
@@ -102,231 +101,47 @@ describe('time-context invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a reading appended after request execution starts', async () => {
|
||||
it('requires browser-zone policy and timestamp to match current-turn request provenance', async () => {
|
||||
const ctx = await setup()
|
||||
const session = preparing(1, 1)
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const policy = 'Browser time zone for this request: Asia/Shanghai. '
|
||||
+ 'Interpret otherwise-unqualified dates and times in this zone.'
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading()))
|
||||
}).toThrow(/must precede request\/header/)
|
||||
})
|
||||
|
||||
it('derives Session and client zones from their original durable owners', async () => {
|
||||
const ctx = await setup()
|
||||
const id = SessionId('time-invariant-zones')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: SECOND,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'travel request' }],
|
||||
source: { kind: 'user', rpcId: 'travel-request', clientTimeZone: 'America/New_York' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading(
|
||||
ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T08:00:00+08:00[Asia/Shanghai]',
|
||||
'Asia/Shanghai',
|
||||
'America/New_York',
|
||||
)))
|
||||
policy,
|
||||
), SECOND + 456))
|
||||
}).not.toThrow()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T08:00:00+08:00[Asia/Shanghai]',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Shanghai',
|
||||
)))
|
||||
}).toThrow(/does not match the Session and current request zones/)
|
||||
ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading()))
|
||||
}).toThrow(/browser-zone text/)
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading(
|
||||
ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T00:00:00+00:00[UTC]',
|
||||
'Asia/Shanghai',
|
||||
'America/New_York',
|
||||
policy,
|
||||
)))
|
||||
}).toThrow(/rendered timestamp does not match the Session time zone/)
|
||||
}).toThrow(/rendered timestamp does not match the unique browser zone/)
|
||||
})
|
||||
|
||||
it('rejects a durable reading whose Session zone cannot format the timestamp', async () => {
|
||||
it('rejects invalid browser provenance loaded across the durable boundary', async () => {
|
||||
const ctx = await setup()
|
||||
const id = SessionId('time-invariant-invalid-zone')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: SECOND,
|
||||
timeZone: 'Invalid/Zone',
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'invalid zone request' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
const timeZone = 'Not/A_Real_Zone'
|
||||
const policy = `Browser time zone for this request: ${timeZone}. `
|
||||
+ 'Interpret otherwise-unqualified dates and times in this zone.'
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, event(reading(
|
||||
ctx.emit('session/event', preparing(1, 1, timeZone), event(reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T00:00:00+00:00[UTC]',
|
||||
'Invalid/Zone',
|
||||
`2026-07-14T00:00:00+00:00[${timeZone}]`,
|
||||
policy,
|
||||
)))
|
||||
}).toThrow(/Session time zone cannot format its durable timestamp/)
|
||||
})
|
||||
|
||||
it('rejects a malformed reading seeded after companion setup', async () => {
|
||||
const ctx = await setup()
|
||||
const id = SessionId('time-invariant-future-seed')
|
||||
const text = reading(
|
||||
'1',
|
||||
'1',
|
||||
'model-visible message',
|
||||
'2026-07-14T00:00:00+00:00[UTC]',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Shanghai',
|
||||
)
|
||||
expect(() => ctx.sessions.create(id, {
|
||||
meta: { timeZone: 'Asia/Shanghai' },
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: SECOND, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: SECOND,
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'seeded request' }],
|
||||
source: { kind: 'user', rpcId: 'seeded-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}),
|
||||
},
|
||||
{ type: 'step/start', seq: 2, time: SECOND, data: { turn: 1, step: 1 } },
|
||||
{ ...event(text), seq: 3, surfaceOp: 'append' },
|
||||
],
|
||||
})).toThrow(/rendered timestamp does not match the Session time zone/)
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a time-context source that duplicates request authority', async () => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
const duplicate: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: {
|
||||
...base.data,
|
||||
source: { ...base.data.source, authority: {} } as never,
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), duplicate)
|
||||
}).toThrow(/must carry only the exact snapshot text/)
|
||||
})
|
||||
|
||||
it('rejects snapshot provenance whose section differs from the model-visible text', async () => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
const mismatched: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: {
|
||||
...base.data,
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'time-context', text: 'different' }],
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), mismatched)
|
||||
}).toThrow(/must carry only the exact snapshot text/)
|
||||
})
|
||||
|
||||
it('rejects snapshot provenance whose sections are only array-like', async () => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
const arrayLike: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: {
|
||||
...base.data,
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: { 0: { name: 'time-context', text: reading() }, length: 1 },
|
||||
} as never,
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), arrayLike)
|
||||
}).toThrow(/must carry only the exact snapshot text/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'matched non-string text',
|
||||
{ type: 'text', text: 7 },
|
||||
[{ name: 'time-context', text: 7 }],
|
||||
/must contain exactly one text block/,
|
||||
],
|
||||
[
|
||||
'an extra text-block field',
|
||||
{ type: 'text', text: reading(), extra: true },
|
||||
[{ name: 'time-context', text: reading() }],
|
||||
/must contain exactly one text block/,
|
||||
],
|
||||
[
|
||||
'an extra section field',
|
||||
{ type: 'text', text: reading() },
|
||||
[{ name: 'time-context', text: reading(), extra: true }],
|
||||
/must carry only the exact snapshot text/,
|
||||
],
|
||||
] as const)(
|
||||
'rejects snapshot provenance with %s',
|
||||
async (_name, block, sections, diagnostic) => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
const malformed: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: {
|
||||
...base.data,
|
||||
content: [block as never],
|
||||
source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never,
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), malformed)
|
||||
}).toThrow(diagnostic)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects package-owned provenance without snapshot sections', async () => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
const unformed: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: {
|
||||
...base.data,
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), unformed)
|
||||
}).toThrow(/must carry only the exact snapshot text/)
|
||||
}).toThrow(/browser zone cannot format/)
|
||||
})
|
||||
|
||||
it('validates each existing reading against its preceding durable prefix', async () => {
|
||||
@@ -377,22 +192,23 @@ describe('time-context invariants', () => {
|
||||
.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('rejects a reading before step/start', async () => {
|
||||
const ctx = await setup()
|
||||
const session = Session.create(SessionId('time-invariant-turn-only'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => { ctx.emit('session/event', session, event(reading())) }).toThrow(/follow step\/start/)
|
||||
})
|
||||
|
||||
it('rejects a reading outside its open preparation', async () => {
|
||||
it('rejects a reading outside a prompt boundary', async () => {
|
||||
const ctx = await setup()
|
||||
const ended = preparing(1, 1)
|
||||
ended.append('step/end', { turn: 1, step: 1 })
|
||||
expect(() => { ctx.emit('session/event', ended, event(reading())) })
|
||||
.toThrow(/follow step\/start/)
|
||||
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/follow step\/start/)
|
||||
const notEntered = Session.create(SessionId('time-invariant-turn-only'))
|
||||
notEntered.append('turn/start', { turn: 1 })
|
||||
expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/follow step\/start/)
|
||||
expect(() => {
|
||||
ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
|
||||
}).toThrow(/inside an open turn/)
|
||||
const requested = preparing(1, 1)
|
||||
requested.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'model' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
expect(() => { ctx.emit('session/event', requested, event(reading())) }).toThrow(/precede request\/header/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -409,6 +225,7 @@ describe('time-context invariants', () => {
|
||||
['ignored', SECOND, [], /exactly one text block/],
|
||||
['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
|
||||
['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
|
||||
[reading(), SECOND, [{ type: 'text', text: reading(), extra: true }], /exactly one text block/],
|
||||
] as const)('rejects an incoherent durable reading', async (text, time, content, message) => {
|
||||
const ctx = await setup()
|
||||
const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1
|
||||
@@ -421,6 +238,55 @@ describe('time-context invariants', () => {
|
||||
}).toThrow(message)
|
||||
})
|
||||
|
||||
it('requires exact snapshot provenance without copied request authority', async () => {
|
||||
const ctx = await setup()
|
||||
const base = event(reading())
|
||||
for (const source of [
|
||||
{ kind: 'plugin', plugin: 'time-context' },
|
||||
{ ...base.data.source, authority: {} },
|
||||
{
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'time-context', text: 'different' }],
|
||||
},
|
||||
{
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: { 0: { name: 'time-context', text: reading() }, length: 1 },
|
||||
},
|
||||
{
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'time-context', text: reading(), extra: true }],
|
||||
},
|
||||
]) {
|
||||
const malformed: SessionEvent<'user/message'> = {
|
||||
...base,
|
||||
data: { ...base.data, source: source as never },
|
||||
}
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), malformed) })
|
||||
.toThrow(/must carry only the exact snapshot text/)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates a seeded Session created after invariant registration', async () => {
|
||||
const ctx = await setup()
|
||||
const text = reading('1', '2', 'step context')
|
||||
expect(() => {
|
||||
ctx.sessions.create(SessionId('time-invariant-created-invalid'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: SECOND, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: SECOND, data: { turn: 1, step: 1 } },
|
||||
{ ...event(text), seq: 2, surfaceOp: 'append' },
|
||||
],
|
||||
})
|
||||
}).toThrow(/expected turn 1\/step 1/)
|
||||
expect(ctx.sessions.get(SessionId('time-invariant-created-invalid'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores context messages owned by another package', async () => {
|
||||
const ctx = await setup()
|
||||
const other = event('unrelated', SECOND + 456, undefined, 'other')
|
||||
|
||||
@@ -1,64 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import * as timeContext from '@deepseek-ai/dsh-time-context'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
deriveClientTimeZoneContext,
|
||||
} from '@deepseek-ai/dsh-time-context'
|
||||
import { renderTimeZoneContext } from '../src/request-zone.ts'
|
||||
deriveBrowserTimeZoneContext,
|
||||
renderBrowserTimeZoneContext,
|
||||
} from '../src/request-zone.ts'
|
||||
|
||||
function request(clientTimeZone?: unknown) {
|
||||
function browserMessage(timeZone: string): UserMessage {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
source: clientTimeZone === undefined
|
||||
? { kind: 'user' }
|
||||
: { kind: 'user', rpcId: 'request-zone', clientTimeZone } as never,
|
||||
content: [{ type: 'text', text: timeZone }],
|
||||
source: { kind: 'user', rpcId: `rpc-${timeZone}`, clientTimeZone: timeZone } as never,
|
||||
})
|
||||
}
|
||||
|
||||
describe('request-zone derivation', () => {
|
||||
it('publishes derivation without exposing the internal renderer', () => {
|
||||
expect(timeContext.deriveClientTimeZoneContext).toBe(deriveClientTimeZoneContext)
|
||||
expect('renderTimeZoneContext' in timeContext).toBe(false)
|
||||
})
|
||||
|
||||
it('derives missing, one resolved zone, and sorted unique mixed zones', () => {
|
||||
describe('browser request-zone context', () => {
|
||||
it('derives missing, unique, and sorted mixed zones from user-rpc messages only', () => {
|
||||
const plugin = createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'fixture' },
|
||||
content: [{ type: 'text', text: 'plugin' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
expect(deriveClientTimeZoneContext([plugin, request(), request(1)])).toEqual({ kind: 'missing' })
|
||||
expect(deriveClientTimeZoneContext([createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
})])).toEqual({ kind: 'missing' })
|
||||
expect(deriveClientTimeZoneContext([
|
||||
request('Asia/Shanghai'),
|
||||
request('Asia/Shanghai'),
|
||||
expect(deriveBrowserTimeZoneContext([plugin])).toEqual({ kind: 'missing' })
|
||||
expect(deriveBrowserTimeZoneContext([
|
||||
browserMessage('Asia/Shanghai'),
|
||||
browserMessage('Asia/Shanghai'),
|
||||
])).toEqual({ kind: 'resolved', timeZone: 'Asia/Shanghai' })
|
||||
expect(deriveClientTimeZoneContext([
|
||||
request('Asia/Shanghai'),
|
||||
request('America/New_York'),
|
||||
expect(deriveBrowserTimeZoneContext([
|
||||
browserMessage('Asia/Shanghai'),
|
||||
browserMessage('America/New_York'),
|
||||
])).toEqual({
|
||||
kind: 'mixed',
|
||||
timeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders resolved, mixed, and unavailable policy lines', () => {
|
||||
expect(renderTimeZoneContext('Asia/Shanghai', {
|
||||
kind: 'resolved',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})).toBe(
|
||||
'Session time zone: Asia/Shanghai.\nClient time zone for this request: Asia/Shanghai.',
|
||||
)
|
||||
expect(renderTimeZoneContext('UTC', {
|
||||
kind: 'mixed',
|
||||
timeZones: ['America/New_York', 'UTC'],
|
||||
})).toBe(
|
||||
'Session time zone: UTC.\nClient time zone for this request: mixed ["America/New_York","UTC"].',
|
||||
)
|
||||
expect(renderTimeZoneContext(undefined, { kind: 'missing' })).toBe(
|
||||
'Session time zone: unavailable.\nClient time zone for this request: missing.',
|
||||
)
|
||||
it('renders one explicit model policy for every context', () => {
|
||||
expect(renderBrowserTimeZoneContext({ kind: 'resolved', timeZone: 'Asia/Shanghai' }))
|
||||
.toContain('Interpret otherwise-unqualified dates and times in this zone.')
|
||||
expect(renderBrowserTimeZoneContext({
|
||||
kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
})).toContain('mixed ["America/New_York","Asia/Shanghai"]')
|
||||
expect(renderBrowserTimeZoneContext({ kind: 'missing' })).toContain('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -53,11 +53,13 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function openMessageTurn(session: Session, turn: number): void {
|
||||
function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
source: clientTimeZone === undefined
|
||||
? { kind: 'user' }
|
||||
: { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -79,35 +81,24 @@ async function fire(
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
messages: UserMessage[] = [],
|
||||
): Promise<void> {
|
||||
const fallback = messages.length === 0
|
||||
? createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'time-context-test-proposal' },
|
||||
})
|
||||
: undefined
|
||||
const proposal = fallback === undefined ? messages : [fallback]
|
||||
const proposed = createUserMessage({
|
||||
content: [{ type: 'text', text: 'request proposal' }],
|
||||
source: { kind: 'plugin', plugin: 'time-context-test' },
|
||||
})
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
{ messages: proposal, turn, step, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: proposal }),
|
||||
{ messages: [proposed], turn, step, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [proposed] }),
|
||||
)
|
||||
if (decision.kind === 'enter') {
|
||||
for (const message of decision.messages) {
|
||||
if (message.id === fallback?.id) continue
|
||||
if (message === proposed) continue
|
||||
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rpcMessage(text: string, clientTimeZone: string): UserMessage {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user', rpcId: `rpc-${text}`, clientTimeZone } as never,
|
||||
})
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
@@ -161,84 +152,22 @@ function requestText(request: GenerateOptions): string {
|
||||
}
|
||||
|
||||
describe('durable step context', () => {
|
||||
it('uses the immutable Session zone and the current request message zone', async () => {
|
||||
const { ctx } = await mount()
|
||||
const id = SessionId('session-zone')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: BASE,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = sessionAgent(session)
|
||||
|
||||
await fire(ctx, agent, 1, 1, SIGNAL, [
|
||||
rpcMessage('local request', 'Asia/Shanghai'),
|
||||
])
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'2026-07-14T08:00:00+08:00[Asia/Shanghai]',
|
||||
)
|
||||
expect(contextTexts(session)[0]).toContain('Session time zone: Asia/Shanghai.')
|
||||
expect(contextTexts(session)[0]).toContain('Client time zone for this request: Asia/Shanghai.')
|
||||
const reading = session.events.at(-1)
|
||||
expect(reading).toMatchObject({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
},
|
||||
})
|
||||
|
||||
await fire(ctx, agent, 1, 2, SIGNAL, [
|
||||
rpcMessage('same zone again', 'Asia/Shanghai'),
|
||||
])
|
||||
expect(contextTexts(session)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports sorted mixed zones from the current request chain without changing the Session zone', async () => {
|
||||
const { ctx } = await mount()
|
||||
const id = SessionId('mixed-zone')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: BASE,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', rpcMessage('first tab', 'Asia/Shanghai'), {
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1, SIGNAL, [
|
||||
rpcMessage('second tab', 'America/New_York'),
|
||||
])
|
||||
|
||||
expect(contextTexts(session)[0]).toContain('Session time zone: Asia/Shanghai.')
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].',
|
||||
)
|
||||
})
|
||||
|
||||
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
|
||||
const session = Session.create(SessionId('first'))
|
||||
openMessageTurn(session, 1)
|
||||
openMessageTurn(session, 1, 'Asia/Shanghai')
|
||||
vi.setSystemTime(BASE + 90_061_000)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toEqual([
|
||||
'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Session time zone: unavailable.\n'
|
||||
+ 'Client time zone for this request: missing.\n'
|
||||
+ 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n'
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
expect(event?.type).toBe('user/message')
|
||||
if (event?.type !== 'user/message') throw new Error('missing time context')
|
||||
const text = event.data.content.find(block => block.type === 'text')?.text
|
||||
if (text === undefined) throw new Error('missing time-context text')
|
||||
// The reading is a `snapshot`-form context: one named contribution whose
|
||||
// text is exactly what the model read, so a consumer attributes it without
|
||||
// re-splitting prose.
|
||||
@@ -248,7 +177,9 @@ describe('durable step context', () => {
|
||||
form: 'snapshot',
|
||||
sections: [{
|
||||
name: 'time-context',
|
||||
text,
|
||||
text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n'
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
}],
|
||||
})
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
@@ -281,12 +212,40 @@ describe('durable step context', () => {
|
||||
|
||||
expect(contextTexts(session)[1]).toBe(
|
||||
'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
|
||||
+ 'Session time zone: unavailable.\n'
|
||||
+ 'Client time zone for this request: missing.\n'
|
||||
+ 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\n'
|
||||
+ 'Elapsed since the preceding step context: 1m 1s.',
|
||||
)
|
||||
})
|
||||
|
||||
it('formats in one browser zone and falls back when steering supplies mixed zones', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'UTC' })
|
||||
const resolved = Session.create(SessionId('browser-zone-resolved'))
|
||||
openMessageTurn(resolved, 1, 'America/New_York')
|
||||
await fire(ctx, sessionAgent(resolved), 1, 1)
|
||||
expect(contextTexts(resolved)[0]).toContain(
|
||||
'2026-07-13T20:00:00-04:00[America/New_York]\n'
|
||||
+ 'Browser time zone for this request: America/New_York. '
|
||||
+ 'Interpret otherwise-unqualified dates and times in this zone.',
|
||||
)
|
||||
|
||||
const mixed = Session.create(SessionId('browser-zone-mixed'))
|
||||
openMessageTurn(mixed, 1, 'Asia/Shanghai')
|
||||
mixed.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'steering from another browser' }],
|
||||
source: {
|
||||
kind: 'user',
|
||||
rpcId: 'mixed-steer',
|
||||
clientTimeZone: 'America/New_York',
|
||||
} as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
await fire(ctx, sessionAgent(mixed), 1, 1)
|
||||
expect(contextTexts(mixed)[0]).toContain(
|
||||
'2026-07-14T00:00:00+00:00[UTC]\n'
|
||||
+ 'Browser time zone for this request: mixed ["America/New_York","Asia/Shanghai"]. '
|
||||
+ 'Ask the user to clarify otherwise-unqualified dates and times.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = Session.create(SessionId('later-step-boundary'))
|
||||
@@ -427,20 +386,6 @@ describe('configuration and lifecycle', () => {
|
||||
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
|
||||
})
|
||||
|
||||
it('fails loud when a persisted Session names an invalid zone', async () => {
|
||||
const { ctx } = await mount()
|
||||
const id = SessionId('invalid-session-zone')
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: BASE,
|
||||
timeZone: 'Not/A_Real_Zone',
|
||||
})
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await expect(fire(ctx, sessionAgent(session), 1, 1)).rejects.toThrow(/invalid Session time zone/)
|
||||
})
|
||||
|
||||
it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => {
|
||||
const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN]
|
||||
for (const refreshIntervalMs of invalid) {
|
||||
@@ -462,26 +407,13 @@ describe('configuration and lifecycle', () => {
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets an already-stopped direct registration delegate without contributing', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const stop = timeContext.apply(ctx, {})
|
||||
stop()
|
||||
const session = Session.create(SessionId('stopped-direct-registration'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('real agent-loop request history', () => {
|
||||
it.each([
|
||||
['throws', 0],
|
||||
['cancels', 0],
|
||||
] as const)('does not persist context when a downstream pre-step listener %s', async (mode, expectedContexts) => {
|
||||
['throws'],
|
||||
['cancels'],
|
||||
] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/pre-step', ({ agent: subject }, next) => {
|
||||
@@ -494,170 +426,13 @@ describe('real agent-loop request history', () => {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(contextTexts(agent.session)).toHaveLength(expectedContexts)
|
||||
expect(contextTexts(agent.session)).toHaveLength(0)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('leaves steering that arrives after claim for the next step and derives fresh context', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
const entered = Promise.withResolvers<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('late-steering'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup(rpcMessage('start in Shanghai', 'Asia/Shanghai'))
|
||||
await entered.promise
|
||||
agent.steer(rpcMessage('switch to New York', 'America/New_York'))
|
||||
blocked = false
|
||||
release.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
expect(requestText(adapter.requests[0]!)).toContain('start in Shanghai')
|
||||
expect(requestText(adapter.requests[0]!)).not.toContain('switch to New York')
|
||||
expect(requestText(adapter.requests[0]!)).toContain('Client time zone for this request: Asia/Shanghai.')
|
||||
expect(requestText(adapter.requests[1]!)).toContain('switch to New York')
|
||||
expect(requestText(adapter.requests[1]!)).toContain(
|
||||
'Client time zone for this request: mixed ["America/New_York","Asia/Shanghai"].',
|
||||
)
|
||||
expect(contextTexts(agent.session)).toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not let time context create an initial step after downstream suppression', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/pre-step', async (_payload, next) => {
|
||||
const decision = await next()
|
||||
return decision.kind === 'reject' ? decision : { kind: 'enter', messages: [] }
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('suppressed-preparation'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
agent.followup(rpcMessage('suppress this prompt', 'Asia/Shanghai'))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(contextTexts(agent.session)).toEqual([])
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not revive an empty continuation after a completed step', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/turn-stopping', ({ agent: subject }) => {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'pending context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
})
|
||||
ctx.on('agent/pre-step', async ({ step }, next) => {
|
||||
const decision = await next()
|
||||
return step === 1 || decision.kind === 'reject'
|
||||
? decision
|
||||
: { kind: 'enter', messages: [] }
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
agent.followup(rpcMessage('finish once', 'Asia/Shanghai'))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('preserves post-claim steering without persisting failed-turn context', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('resumed')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
const entered = Promise.withResolvers<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('cancelled-assembly'), { provider: 'mock', model: 'mock' })
|
||||
const steering = rpcMessage('preserve this steering', 'America/New_York')
|
||||
|
||||
agent.followup(rpcMessage('start', 'Asia/Shanghai'))
|
||||
await entered.promise
|
||||
agent.steer(steering)
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
blocked = false
|
||||
release.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(contextTexts(agent.session)).toHaveLength(0)
|
||||
expect(agent.inbox.nextStep).toEqual([steering])
|
||||
expect(agent.inbox.nextStep.some(message =>
|
||||
message.source.kind === 'plugin' && message.source.plugin === 'time-context')).toBe(false)
|
||||
|
||||
agent.followup(rpcMessage('wake', 'America/New_York'))
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(requestText(adapter.requests[0]!)).toContain('preserve this steering')
|
||||
expect(requestText(adapter.requests[0]!)).toContain('Time sampled while preparing turn 2, step 1:')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not contribute after its disposer wins an in-flight pre-step', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const stopTimeContext = timeContext.apply(ctx, {})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/pre-step', async (_payload, next) => {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('dispose-inflight-pre-step'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
agent.followup(rpcMessage('continue without disposed context', 'Asia/Shanghai'))
|
||||
await entered.promise
|
||||
stopTimeContext()
|
||||
release.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(requestText(adapter.requests[0]!)).not.toContain('Time sampled while preparing')
|
||||
expect(contextTexts(agent.session)).toEqual([])
|
||||
expect(agent.inbox.nextStep).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not add a reading to an empty tool continuation and leaves system headers unchanged', async () => {
|
||||
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
@@ -678,9 +453,11 @@ describe('real agent-loop request history', () => {
|
||||
const contexts = agent.session.events.filter(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(contexts).toHaveLength(adapter.requests.length)
|
||||
expect(starts).toHaveLength(adapter.requests.length)
|
||||
expect(contexts[0]!.seq).toBeGreaterThan(starts[0]!.seq)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
|
||||
}
|
||||
expect(contexts.every(event => event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context'
|
||||
&& event.surfaceOp === 'append')).toBe(true)
|
||||
@@ -691,7 +468,8 @@ describe('real agent-loop request history', () => {
|
||||
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.')
|
||||
expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(secondRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
|
||||
|
||||
for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build both public entries separately so each inlines the shared request-zone helper. */
|
||||
/** Build both public entries separately so each inlines shared internal helpers. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
|
||||
Reference in New Issue
Block a user