Merge master into codex/simp-session-log-representation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-time-context
|
||||
|
||||
Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md).
|
||||
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -8,36 +8,51 @@ Opt-in dynamic system-prompt context with the current zoned time and elapsed tim
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
config:
|
||||
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
|
||||
refreshIntervalMs: 60000 # default; 0 refreshes on every step
|
||||
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
||||
```
|
||||
|
||||
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 zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work.
|
||||
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 zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load.
|
||||
|
||||
## Message baseline
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends 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.
|
||||
|
||||
The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time.
|
||||
## Timing semantics
|
||||
|
||||
The loop records the dynamic section in full `request/header` snapshots. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history.
|
||||
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `context/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 model-visible message, including the prompt that opened the turn. 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 a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
||||
|
||||
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 at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Temporal system prompt
|
||||
### Preparation-time temporal context
|
||||
|
||||
**What the model sees**: Every request in an active turn includes the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; `<duration-or-unavailable>` is compact whole-second units or the first-turn fallback.
|
||||
**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
|
||||
|
||||
**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate.
|
||||
**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
|
||||
|
||||
#### Temporal context section
|
||||
#### First step
|
||||
|
||||
```markdown
|
||||
Current time: <timestamp>
|
||||
Time since previous message: <duration-or-unavailable>.
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
#### Later steps
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed.
|
||||
- **Whole-second display** — timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000.
|
||||
- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp.
|
||||
- **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.
|
||||
- **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ.
|
||||
- **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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-time-context",
|
||||
"description": "Opt-in system-prompt context with the current time and elapsed time since the previous message",
|
||||
"description": "Opt-in durable per-step context with the current time and elapsed time",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -26,12 +26,12 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Opt-in request-time clock context. Active turns receive the current zoned
|
||||
* time and elapsed time since the preceding model-visible message. The loop
|
||||
* logs each rendered value as request-header state rather than conversation
|
||||
* history.
|
||||
* Opt-in request-preparation clock context. Eligible pre-step attempts append
|
||||
* durable, source-attributed time readings to conversation history.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-time-context
|
||||
*/
|
||||
@@ -10,77 +8,30 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
|
||||
/** The system-prompt registry that owns the dynamic request section. */
|
||||
export const inject = ['systemPrompt']
|
||||
/** The agent registry that owns the pre-step lifecycle seam. */
|
||||
export const inject = ['agents']
|
||||
|
||||
/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
|
||||
timeZone?: string
|
||||
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
|
||||
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery validation and defaults for {@link Config}. */
|
||||
/** Schemastery validation for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
timeZone: z.string(),
|
||||
refreshIntervalMs: z.number().default(60_000),
|
||||
refreshIntervalMs: z.number(),
|
||||
})
|
||||
|
||||
interface OpenTurn {
|
||||
turn: number
|
||||
startSeq: number
|
||||
}
|
||||
|
||||
/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */
|
||||
interface RenderState {
|
||||
turn: number
|
||||
renderedAt: number
|
||||
previousMessageTime: number | undefined
|
||||
text: string
|
||||
}
|
||||
|
||||
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
|
||||
|
||||
function openTurn(agent: Agent): OpenTurn | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
switch (event.type) {
|
||||
case 'turn/end':
|
||||
return undefined
|
||||
case 'turn/start':
|
||||
return { turn: event.data.turn, startSeq: event.seq }
|
||||
default:
|
||||
// Merge-extensible session events: only turn boundaries matter here.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find the latest model-visible timestamp strictly before one turn boundary. */
|
||||
function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.seq >= turnStartSeq) continue
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return event.time
|
||||
default:
|
||||
// Merge-extensible session events: non-surface records are not messages.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
|
||||
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
|
||||
const parts = Object.fromEntries(
|
||||
@@ -107,31 +58,85 @@ function formatDuration(elapsedMs: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return event.time
|
||||
default:
|
||||
// Merge-extensible session events: non-surface records are not messages.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find the preceding time-context event within the open turn. */
|
||||
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find this plugin's latest durable injection, including a shadowed surface event. */
|
||||
function latestInjectionTime(agent: Agent): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function renderText(
|
||||
now: number,
|
||||
turn: number,
|
||||
step: number,
|
||||
previous: number | undefined,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const elapsed = previous === undefined
|
||||
? 'unavailable (no earlier message in this session)'
|
||||
: formatDuration(now - previous)
|
||||
return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.`
|
||||
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
|
||||
const baseline = step === 1 ? 'model-visible message' : 'step context'
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
|
||||
}
|
||||
|
||||
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
|
||||
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
if (refreshIntervalMs !== undefined && (
|
||||
!Number.isSafeInteger(refreshIntervalMs)
|
||||
|| refreshIntervalMs < 0
|
||||
)) {
|
||||
throw new TypeError(
|
||||
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the request-time clock section for the lifetime of `ctx`.
|
||||
* @param ctx - plugin context; the section registration is disposed with it.
|
||||
* @param config - validated time zone and intra-turn refresh interval.
|
||||
* @throws when the time zone or refresh interval is invalid.
|
||||
* 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.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const timeZone = config.timeZone
|
||||
const refreshIntervalMs = config.refreshIntervalMs as number
|
||||
if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
|
||||
throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`)
|
||||
}
|
||||
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
let formatter: Intl.DateTimeFormat
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -152,32 +157,29 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error(message, { cause: error })
|
||||
}
|
||||
const resolvedTimeZone = formatter.resolvedOptions().timeZone
|
||||
const states = new WeakMap<Agent, RenderState>()
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'context:time',
|
||||
order: 10,
|
||||
text(context: AssembleContext): string {
|
||||
const agent = context.agent
|
||||
if (agent === undefined) return ''
|
||||
const currentTurn = openTurn(agent)
|
||||
if (currentTurn === undefined) return ''
|
||||
|
||||
const now = Date.now()
|
||||
const prior = states.get(agent)
|
||||
if (prior !== undefined
|
||||
&& prior.turn === currentTurn.turn
|
||||
&& now >= prior.renderedAt
|
||||
&& now - prior.renderedAt < refreshIntervalMs) {
|
||||
return prior.text
|
||||
}
|
||||
|
||||
const previous = prior?.turn === currentTurn.turn
|
||||
? prior.previousMessageTime
|
||||
: previousMessageTime(agent, currentTurn.startSeq)
|
||||
const text = renderText(now, previous, formatter, resolvedTimeZone)
|
||||
states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text })
|
||||
return text
|
||||
},
|
||||
})
|
||||
ctx.on('agent/pre-step', (
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
_fullSystemPrompt: string,
|
||||
_sessionPrefix: readonly Message[],
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (signal.aborted) return
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
if (lastInjection !== undefined
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return
|
||||
}
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
agent.inject(
|
||||
[{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
|
||||
{ source: { kind: 'plugin', plugin: name } },
|
||||
)
|
||||
}, { prepend: true })
|
||||
}
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
persona: 'Test the time-context plugin.'
|
||||
welcome: 'time-context e2e ready.'
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext: false
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
|
||||
@@ -12,7 +12,8 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
const FIRST_REPLY = 'You said: "first". Try "echo <something>" to see a tool call.'
|
||||
const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
|
||||
const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:'
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
@@ -60,7 +61,7 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) {
|
||||
if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo <something>" to see a tool call.\n> ')) {
|
||||
sentSecond = true
|
||||
proc.stdin.end('second\n')
|
||||
}
|
||||
@@ -84,12 +85,12 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
|
||||
}
|
||||
|
||||
describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
it('uses the process zone and persists both first-turn and elapsed-time request context', async () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
const { stdout, stderr } = await runTwoTurns()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('time-context e2e ready.')
|
||||
expect(stdout).toContain(FIRST_REPLY)
|
||||
expect(stdout).toContain('You said: "second".')
|
||||
expect(stdout).toContain(SECOND_REPLY)
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
@@ -97,19 +98,28 @@ describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
|
||||
const firstHeader = events.find(event => event.type === 'request/header')
|
||||
if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event')
|
||||
expect(firstHeader.data.header.system).toMatch(
|
||||
/Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||
const contexts = events.filter(event => event.type === 'context/message')
|
||||
const starts = events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(starts).toHaveLength(2)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
|
||||
expect(contexts[index]!.surfaceOp).toBe('append')
|
||||
expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
|
||||
}
|
||||
const contextText = contexts.map(event => event.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n'))
|
||||
expect(contextText[0]).toMatch(
|
||||
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||
)
|
||||
expect(firstHeader.data.header.system).toContain(
|
||||
'Time since previous message: unavailable (no earlier message in this session).',
|
||||
expect(contextText[0]).toMatch(
|
||||
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
||||
)
|
||||
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
|
||||
|
||||
const finalSystem = foldRequestHeader(events)?.system
|
||||
expect(finalSystem).toContain('[Asia/Shanghai]')
|
||||
expect(finalSystem).toMatch(
|
||||
/Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
||||
)
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as timeContext from '@deepseek-ai/dsh-time-context'
|
||||
import type { Config } from '@deepseek-ai/dsh-time-context'
|
||||
|
||||
const BASE = Date.parse('2026-07-14T00:00:00.000Z')
|
||||
const ORIGINAL_TIME_ZONE = process.env['TZ']
|
||||
const SIGNAL = new AbortController().signal
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['TZ'] = 'UTC'
|
||||
@@ -30,18 +31,29 @@ afterEach(() => {
|
||||
|
||||
async function mount(config: Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(timeContext, config)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
return { id: AgentId(id), session } as unknown as Agent
|
||||
}
|
||||
|
||||
async function sectionText(ctx: Context, agent?: Agent): Promise<string | undefined> {
|
||||
const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent })
|
||||
return assembly.sections.find(section => section.name === 'context:time')?.text
|
||||
return {
|
||||
id: AgentId(id),
|
||||
options: {},
|
||||
session,
|
||||
status: 'running',
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content, options) {
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function openMessageTurn(session: Session, turn: number): void {
|
||||
@@ -52,6 +64,28 @@ function openMessageTurn(session: Session, turn: number): void {
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function contextTexts(session: Session): string[] {
|
||||
const texts: string[] = []
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context') {
|
||||
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
async function fire(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
): Promise<void> {
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal)
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
@@ -89,184 +123,186 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
|
||||
async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(timeContext, config)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('temporal section rendering', () => {
|
||||
it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => {
|
||||
const { ctx } = await mount()
|
||||
function requestText(request: GenerateOptions): string {
|
||||
return request.messages
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('durable step context', () => {
|
||||
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
|
||||
const session = new Session(SessionId('first'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toBe(
|
||||
'Current time: 2026-07-14T00:00:00+00:00[UTC]\n'
|
||||
+ 'Time since previous message: unavailable (no earlier message in this session).',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a non-UTC numeric offset and all compact duration units', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
|
||||
const session = new Session(SessionId('offset'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'previous' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
vi.setSystemTime(BASE + 90_061_000)
|
||||
openMessageTurn(session, 2)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toBe(
|
||||
'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Time since previous message: 1d 1h 1m 1s.',
|
||||
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'
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
expect(event?.type).toBe('context/message')
|
||||
if (event?.type !== 'context/message') throw new Error('missing time context')
|
||||
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('unavailable'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Elapsed since the preceding model-visible message: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps a backward wall-clock adjustment to a zero duration', async () => {
|
||||
it.each([
|
||||
['omitted interval', {}],
|
||||
['zero interval', { refreshIntervalMs: 0 }],
|
||||
] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
|
||||
const { ctx } = await mount(config)
|
||||
const session = new Session(SessionId('later-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 3)
|
||||
await fire(ctx, agent, 3, 1)
|
||||
vi.setSystemTime(BASE + 61_000)
|
||||
|
||||
await fire(ctx, agent, 3, 2)
|
||||
|
||||
expect(contextTexts(session)[1]).toBe(
|
||||
'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
|
||||
+ 'Elapsed since the preceding step context: 1m 1s.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('backward-duration'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'future by adjusted clock' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = new Session(SessionId('later-step-boundary'))
|
||||
openMessageTurn(session, 4)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 4, 2)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('later-step-exhausted'))
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 2)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('backward'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
vi.setSystemTime(BASE - 5_000)
|
||||
openMessageTurn(session, 2)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.')
|
||||
await fire(ctx, agent, 1, 2)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(2)
|
||||
expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
|
||||
})
|
||||
|
||||
const previousMessageCases = [
|
||||
['user/message', (session: Session): void => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
}],
|
||||
['assistant/message', (session: Session): void => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
|
||||
}],
|
||||
['tool/result', (session: Session): void => {
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('previous'),
|
||||
content: [{ type: 'text', text: 'r' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
}],
|
||||
['context/message', (session: Session): void => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'c' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}],
|
||||
['steering/message', (session: Session): void => {
|
||||
session.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 's' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}],
|
||||
] as const
|
||||
it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const original = new Session(SessionId('seed-source'))
|
||||
openMessageTurn(original, 1)
|
||||
await fire(ctx, sessionAgent(original), 1, 1)
|
||||
const user = original.events.find(event => event.type === 'user/message')
|
||||
const reading = original.events.find(event => event.type === 'context/message')
|
||||
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
|
||||
original.append('context/message', {
|
||||
content: [{ type: 'text', text: 'compacted history' }],
|
||||
source: { kind: 'plugin', plugin: 'compact-basic' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
|
||||
sourceEventSeqs: [user.seq, reading.seq],
|
||||
})
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
|
||||
|
||||
it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId(`previous-${_name}`))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
appendPrevious(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
vi.setSystemTime(BASE + 5_000)
|
||||
openMessageTurn(session, 2)
|
||||
const resumed = new Session(SessionId('resumed'), [...original.events])
|
||||
const resumedAgent = sessionAgent(resumed)
|
||||
vi.setSystemTime(BASE + 999)
|
||||
openMessageTurn(resumed, 2)
|
||||
const beforeSkip = resumed.events.length
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.')
|
||||
})
|
||||
await fire(ctx, resumedAgent, 2, 1)
|
||||
|
||||
it('contributes empty text without an active agent turn', async () => {
|
||||
const { ctx } = await mount()
|
||||
expect(await sectionText(ctx)).toBe('')
|
||||
expect(resumed.events).toHaveLength(beforeSkip)
|
||||
expect(contextTexts(resumed)).toHaveLength(1)
|
||||
|
||||
const empty = sessionAgent(new Session(SessionId('empty')))
|
||||
expect(await sectionText(ctx, empty)).toBe('')
|
||||
|
||||
const closedSession = new Session(SessionId('closed'))
|
||||
openMessageTurn(closedSession, 1)
|
||||
closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh policy', () => {
|
||||
it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('interval'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
const first = await sectionText(ctx, agent)
|
||||
vi.setSystemTime(BASE + 30_000)
|
||||
expect(await sectionText(ctx, agent)).toBe(first)
|
||||
vi.setSystemTime(BASE + 60_000)
|
||||
const expired = await sectionText(ctx, agent)
|
||||
expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]')
|
||||
vi.setSystemTime(BASE + 59_000)
|
||||
expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]')
|
||||
})
|
||||
|
||||
it('refreshes every assembly when refreshIntervalMs is zero', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
||||
const session = new Session(SessionId('every-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
const first = await sectionText(ctx, agent)
|
||||
vi.setSystemTime(BASE + 1_000)
|
||||
expect(await sectionText(ctx, agent)).not.toBe(first)
|
||||
await fire(ctx, resumedAgent, 2, 2)
|
||||
|
||||
expect(contextTexts(resumed)).toHaveLength(2)
|
||||
expect(contextTexts(resumed)[1]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('always refreshes for a new turn and keeps the preceding message baseline', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('turn-refresh'))
|
||||
it('applies a positive interval across turns without sharing state between sessions', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const first = new Session(SessionId('interval-first'))
|
||||
const firstAgent = sessionAgent(first, 'first-agent')
|
||||
openMessageTurn(first, 1)
|
||||
await fire(ctx, firstAgent, 1, 1)
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
vi.setSystemTime(BASE + 500)
|
||||
openMessageTurn(first, 2)
|
||||
const beforeSkip = first.events.length
|
||||
await fire(ctx, firstAgent, 2, 1)
|
||||
|
||||
const independent = new Session(SessionId('interval-independent'))
|
||||
openMessageTurn(independent, 1)
|
||||
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
|
||||
|
||||
expect(first.events).toHaveLength(beforeSkip)
|
||||
expect(contextTexts(first)).toHaveLength(1)
|
||||
expect(contextTexts(independent)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('ordering'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
const first = await sectionText(ctx, agent)
|
||||
vi.setSystemTime(BASE + 1_000)
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
vi.setSystemTime(BASE + 2_000)
|
||||
openMessageTurn(session, 2)
|
||||
let ordinarySawContext = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
|
||||
})
|
||||
|
||||
const second = await sectionText(ctx, agent)
|
||||
expect(second).not.toBe(first)
|
||||
expect(second).toContain('Time since previous message: 1s.')
|
||||
})
|
||||
await fire(ctx, agent, 1, 1)
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
await fire(ctx, agent, 1, 2, abort.signal)
|
||||
|
||||
it('keeps refresh caches independent per agent', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const sessionA = new Session(SessionId('agent-a'))
|
||||
const sessionB = new Session(SessionId('agent-b'))
|
||||
const agentA = sessionAgent(sessionA, 'a')
|
||||
const agentB = sessionAgent(sessionB, 'b')
|
||||
openMessageTurn(sessionA, 1)
|
||||
openMessageTurn(sessionB, 1)
|
||||
const aFirst = await sectionText(ctx, agentA)
|
||||
vi.setSystemTime(BASE + 30_000)
|
||||
const bFirst = await sectionText(ctx, agentB)
|
||||
vi.setSystemTime(BASE + 40_000)
|
||||
|
||||
expect(await sectionText(ctx, agentA)).toBe(aFirst)
|
||||
expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]')
|
||||
expect(ordinarySawContext).toBe(true)
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -278,49 +314,79 @@ describe('configuration and lifecycle', () => {
|
||||
const session = new Session(SessionId('system-zone'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain(
|
||||
'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]',
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]')
|
||||
})
|
||||
|
||||
it('fails loud for an invalid explicit zone or an unavailable process zone', async () => {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(AgentRegistry)
|
||||
await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(
|
||||
/invalid IANA timeZone/,
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => {
|
||||
for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/)
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/)
|
||||
})
|
||||
|
||||
it('fails loud when the process system zone cannot be resolved', async () => {
|
||||
vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
|
||||
throw new RangeError('system zone unavailable')
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
|
||||
const unresolved = new Context()
|
||||
await unresolved.plugin(AgentRegistry)
|
||||
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
|
||||
})
|
||||
|
||||
it('removes its section when the plugin fiber disposes', async () => {
|
||||
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) {
|
||||
await expect(mount({ refreshIntervalMs })).rejects.toThrow(
|
||||
'time-context: refreshIntervalMs must be a non-negative safe integer',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes its listener when the plugin fiber disposes', async () => {
|
||||
const { ctx, fiber } = await mount()
|
||||
const session = new Session(SessionId('dispose'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
expect(await sectionText(ctx, agent)).toContain('Current time:')
|
||||
await fire(ctx, agent, 1, 1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(await sectionText(ctx, agent)).toBeUndefined()
|
||||
await fire(ctx, agent, 1, 2)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('real agent-loop request logging', () => {
|
||||
it('refreshes a long turn in the system prompt and records full headers without context history', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')])
|
||||
const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 })
|
||||
describe('real agent-loop request history', () => {
|
||||
it.each([
|
||||
['throws', 'error'],
|
||||
['cancels', 'aborted'],
|
||||
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
let laterSawReading = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
laterSawReading = contextTexts(subject.session).length === 1
|
||||
if (mode === 'throws') throw new Error('later pre-step failure')
|
||||
subject.cancel('later pre-step cancellation')
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(laterSawReading).toBe(true)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind)
|
||||
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)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'tick',
|
||||
description: 'advance fake time',
|
||||
@@ -334,38 +400,53 @@ describe('real agent-loop request logging', () => {
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]')
|
||||
expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]')
|
||||
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(2)
|
||||
expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system)
|
||||
|
||||
vi.setSystemTime(BASE + 361_000)
|
||||
agent.send([{ type: 'text', text: 'again' }])
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const contexts = agent.session.events.filter(event => event.type === 'context/message')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(adapter.requests.length)
|
||||
expect(starts).toHaveLength(adapter.requests.length)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
|
||||
}
|
||||
expect(contexts.every(event => event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context'
|
||||
&& event.surfaceOp === 'append')).toBe(true)
|
||||
|
||||
const firstRequestText = requestText(adapter.requests[0]!)
|
||||
const secondRequestText = requestText(adapter.requests[1]!)
|
||||
expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.')
|
||||
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).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')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('real Loader export path', () => {
|
||||
it('keeps the namespace metadata and boots through unwrapExports', async () => {
|
||||
it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => {
|
||||
expect('default' in timeContext).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(timeContext) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(timeContext)
|
||||
expect(unwrapped.name).toBe('time-context')
|
||||
expect(unwrapped.inject).toEqual(['systemPrompt'])
|
||||
expect(unwrapped.inject).toEqual(['agents'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
|
||||
await ctx.plugin(plugin)
|
||||
const session = new Session(SessionId('loader'))
|
||||
openMessageTurn(session, 1)
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:')
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user