* fix(session-projection): keep released units in checkpoints Since the Cordis rc.11 teardown order unloads the Web layer before the base layer, Session Controller and harness-dev-context release their projection units before their live Sessions detach. The projection cache is still alive at that point and wrote a checkpoint holding only the units that were still registered, replacing the complete record. A blank Session then lost sessionListMetadata and listed as an untitled conversation after every graceful restart. A released unit now stays in the registry as a predecessor: it leaves event drives, snapshots, restores, and change notifications, while checkpoint() keeps each Session's last cell of it without advancing it. Registering the same key again replaces the predecessor and refolds the log. The projection cache keeps replacing whole records. * test(session-projection): assert frozen released units across unload tests Update the remaining unload tests that still expected a released unit to vanish from checkpoint(): user-approval, agent-loop inbox and scope lifecycle, time-context, and the agent registry now assert that the same Session keeps its pre-unload row, that a later event does not advance it, and that a Session created after the unload has no row. The token-meter and goal unload tests also append an event after the unload. Document that a retained handle read still advances a released cell that checkpoint() serves, and that restore() folds only live units, so its refreshed rows omit released units. * docs(session-projection): sync the ProjectionHandle type-equiv block * docs(session-projection): record frozen released units and their upgrade Add the upgrade guide for checkpoint() keeping a released unit's last row, update the registration-handles Agent Note with the frozen-cell decision and the retained predecessor, list both consequences under the registry README's known limitations, note in the projection cache README that a stored row can be frozen at its unit's release, and say that restoreFloor() ignores released predecessors. Update the agent-loop resource-lifecycle unload test, which read checkpoints through an alias, to the frozen-row semantics. * docs(upgrade-guide): mark the released-checkpoint snippet ignore-check * docs(session-projection): state the retained-handle path and narrow a rejected alternative * docs(upgrade-guide): re-record the released-checkpoint guide pairing
658 lines
27 KiB
TypeScript
658 lines
27 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
import { createUserMessage, ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
|
import { Session, SessionId, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
|
import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
import { unsupportedModel, unsupportedInbox, 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'
|
|
|
|
declare module '@deepseek-ai/dsh-llm' {
|
|
interface MessageSourceMap {
|
|
'compaction-basic': { kind: 'compaction-basic' } & ContextFormed
|
|
'time-context-test': { kind: 'time-context-test' } & ContextFormed
|
|
}
|
|
}
|
|
|
|
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'
|
|
vi.useFakeTimers({ toFake: ['Date'] })
|
|
vi.setSystemTime(BASE)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
vi.useRealTimers()
|
|
if (ORIGINAL_TIME_ZONE === undefined) delete process.env['TZ']
|
|
else process.env['TZ'] = ORIGINAL_TIME_ZONE
|
|
})
|
|
|
|
async function mount(config: Config = {}) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
const fiber = await ctx.plugin(timeContext, config)
|
|
return { ctx, fiber }
|
|
}
|
|
|
|
function sessionAgent(session: Session, id = 'agent'): Agent {
|
|
const agent: Agent = {
|
|
id: SessionId(id),
|
|
options: {},
|
|
session,
|
|
model: unsupportedModel(),
|
|
inbox: unsupportedInbox(),
|
|
status: 'running',
|
|
ctx: new Context(),
|
|
send: () => {},
|
|
followup: () => {},
|
|
steer: () => {},
|
|
inject: () => { throw new Error('time-context must append directly to the open step') },
|
|
cancel() {},
|
|
runMaintenance: task => task(new AbortController().signal),
|
|
whenIdle: () => Promise.resolve(),
|
|
}
|
|
return agent
|
|
}
|
|
|
|
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: clientTimeZone === undefined
|
|
? { kind: 'user' }
|
|
: { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never,
|
|
}), { surfaceOp: 'append' })
|
|
}
|
|
|
|
function contextTexts(session: Session): string[] {
|
|
const texts: string[] = []
|
|
for (const event of session.snapshotEvents()) {
|
|
if (event.type === 'user/message'
|
|
&& event.data.source.kind === '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> {
|
|
const proposed = createUserMessage({
|
|
content: [{ type: 'text', text: 'request proposal' }],
|
|
source: { kind: 'time-context-test' },
|
|
})
|
|
const decision = await agentEvents(ctx, agent).waterfall(
|
|
'agent/pre-step',
|
|
{ modelSelection: { provider: 'mock', model: 'mock' }, 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 === proposed) continue
|
|
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
|
}
|
|
}
|
|
}
|
|
|
|
function textResponse(text: string): StreamChunk[] {
|
|
return [
|
|
{ type: 'block-start', index: 0, blockType: 'text' },
|
|
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
|
{ type: 'finish', reason: { kind: 'stop' } },
|
|
]
|
|
}
|
|
|
|
function toolCallResponse(): StreamChunk[] {
|
|
return [
|
|
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
{
|
|
type: 'block-end',
|
|
index: 0,
|
|
block: { type: 'tool-call', id: ToolCallId('tick-1'), name: 'tick', arguments: '{}' },
|
|
},
|
|
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
]
|
|
}
|
|
|
|
class ScriptedAdapter extends LlmAdapter {
|
|
readonly requests: GenerateOptions[] = []
|
|
|
|
constructor(private readonly script: StreamChunk[][]) {
|
|
super()
|
|
}
|
|
|
|
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
this.requests.push(options)
|
|
const chunks = this.script.shift()
|
|
if (chunks === undefined) throw new Error('ScriptedAdapter: script exhausted')
|
|
for (const chunk of chunks) yield chunk
|
|
}
|
|
}
|
|
|
|
async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
|
|
const ctx = new Context()
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await ctx.plugin(timeContext, config)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
return ctx
|
|
}
|
|
|
|
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 = Session.create(SessionId('first'))
|
|
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'
|
|
+ '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.snapshotEvents().at(-1)
|
|
expect(event?.type).toBe('user/message')
|
|
if (event?.type !== 'user/message') throw new Error('missing time context')
|
|
// 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.
|
|
expect(event.data.source).toEqual({
|
|
kind: 'time-context',
|
|
form: 'snapshot',
|
|
sections: [{
|
|
name: 'time-context',
|
|
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')
|
|
})
|
|
|
|
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
|
|
const { ctx } = await mount()
|
|
const session = Session.create(SessionId('unavailable'))
|
|
session.append('turn/start', { turn: 1 })
|
|
|
|
await fire(ctx, sessionAgent(session), 1, 1)
|
|
|
|
expect(contextTexts(session)[0]).toContain(
|
|
'Elapsed since the preceding model-visible message: unavailable.',
|
|
)
|
|
})
|
|
|
|
it('uses the preceding durable step-context timestamp after step one with zero interval', async () => {
|
|
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
|
const session = Session.create(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'
|
|
+ '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'))
|
|
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 = Session.create(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 = Session.create(SessionId('backward'))
|
|
const agent = sessionAgent(session)
|
|
openMessageTurn(session, 1)
|
|
await fire(ctx, agent, 1, 1)
|
|
vi.setSystemTime(BASE - 5_000)
|
|
|
|
await fire(ctx, agent, 1, 2)
|
|
|
|
expect(contextTexts(session)).toHaveLength(2)
|
|
expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
|
|
})
|
|
|
|
it.each([
|
|
['default interval', {}, 600_000],
|
|
['explicit interval', { refreshIntervalMs: 1_000 }, 1_000],
|
|
] as const)('uses a shadowed durable injection after resume at the exact %s threshold', async (_label, config, interval) => {
|
|
const { ctx } = await mount(config)
|
|
const original = Session.create(SessionId('seed-source'))
|
|
openMessageTurn(original, 1)
|
|
await fire(ctx, sessionAgent(original), 1, 1)
|
|
const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user')
|
|
const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'time-context')
|
|
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
|
|
original.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'compacted history' }],
|
|
source: { kind: 'compaction-basic' },
|
|
}), {
|
|
surfaceOp: { op: 'replace', startSeq: user.seq, endSeq: 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')
|
|
|
|
const resumed = Session.create(SessionId('resumed'), original.snapshotEvents())
|
|
const resumedAgent = sessionAgent(resumed)
|
|
vi.setSystemTime(BASE + interval - 1)
|
|
openMessageTurn(resumed, 2)
|
|
const beforeSkip = resumed.snapshotEvents().length
|
|
|
|
await fire(ctx, resumedAgent, 2, 1)
|
|
|
|
expect(resumed.snapshotEvents()).toHaveLength(beforeSkip)
|
|
expect(contextTexts(resumed)).toHaveLength(1)
|
|
|
|
vi.setSystemTime(BASE + interval)
|
|
await fire(ctx, resumedAgent, 2, 2)
|
|
|
|
expect(contextTexts(resumed)).toHaveLength(2)
|
|
expect(contextTexts(resumed)[1]).toContain(
|
|
'Elapsed since the preceding step context: unavailable.',
|
|
)
|
|
})
|
|
|
|
it('applies a positive interval across turns without sharing state between sessions', async () => {
|
|
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
|
const first = Session.create(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.snapshotEvents().length
|
|
await fire(ctx, firstAgent, 2, 1)
|
|
|
|
const independent = Session.create(SessionId('interval-independent'))
|
|
openMessageTurn(independent, 1)
|
|
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
|
|
|
|
expect(first.snapshotEvents()).toHaveLength(beforeSkip)
|
|
expect(contextTexts(first)).toHaveLength(1)
|
|
expect(contextTexts(independent)).toHaveLength(1)
|
|
})
|
|
|
|
it('skips an already-aborted prompt submission', async () => {
|
|
const { ctx } = await mount()
|
|
const session = Session.create(SessionId('ordering'))
|
|
const agent = sessionAgent(session)
|
|
openMessageTurn(session, 1)
|
|
|
|
await fire(ctx, agent, 1, 1)
|
|
const abort = new AbortController()
|
|
abort.abort()
|
|
await fire(ctx, agent, 1, 2, abort.signal)
|
|
|
|
expect(contextTexts(session)).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('configuration and lifecycle', () => {
|
|
it('defaults to the process system zone and retains the zone resolved at plugin load', async () => {
|
|
process.env['TZ'] = 'Asia/Shanghai'
|
|
const { ctx } = await mount()
|
|
process.env['TZ'] = 'America/New_York'
|
|
const session = Session.create(SessionId('system-zone'))
|
|
openMessageTurn(session, 1)
|
|
|
|
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(SessionProjectionRegistry)
|
|
await invalid.plugin(AgentRegistry)
|
|
await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(
|
|
/invalid IANA timeZone/,
|
|
)
|
|
|
|
vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
|
|
throw new RangeError('system zone unavailable')
|
|
})
|
|
const unresolved = new Context()
|
|
await unresolved.plugin(SessionProjectionRegistry)
|
|
await unresolved.plugin(AgentRegistry)
|
|
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system 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) {
|
|
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 = Session.create(SessionId('dispose'))
|
|
const agent = sessionAgent(session)
|
|
openMessageTurn(session, 1)
|
|
await fire(ctx, agent, 1, 1)
|
|
|
|
await fiber.dispose()
|
|
await fire(ctx, agent, 1, 2)
|
|
|
|
expect(contextTexts(session)).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('time-context projection fold edges', () => {
|
|
it('restores browser zones from a JSON checkpoint and suffix without reading the prefix', async () => {
|
|
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
const session = Session.create(SessionId('browser-checkpoint'))
|
|
openMessageTurn(session, 1, 'Asia/Shanghai')
|
|
const cut = session.seq
|
|
const rows = structuredClone(ctx.sessionProjections.checkpoint(session))
|
|
session.append('user/message', createUserMessage({
|
|
content: [],
|
|
source: { kind: 'user', rpcId: 'steer', clientTimeZone: 'America/New_York' } as never,
|
|
}), { surfaceOp: 'append' })
|
|
const events = session.snapshotEvents()
|
|
const suffix = session.snapshotEvents(cut)
|
|
const jsonRows = JSON.parse(JSON.stringify(rows)) as typeof rows
|
|
const restored = ctx.sessionProjections.restore(jsonRows, suffix, cut, session.header, session.inheritedEventCount)
|
|
expect(restored.checkpoint.timeContext).toEqual(ctx.sessionProjections.checkpoint(session).timeContext)
|
|
|
|
const cold = Session.create(SessionId('browser-cold'), events)
|
|
const lookup = vi.spyOn(cold, 'eventAt')
|
|
const snapshot = vi.spyOn(cold, 'snapshotEvents')
|
|
const own = vi.spyOn(cold, 'ownEvents')
|
|
ctx.sessionProjections.hydrate(cold, jsonRows, suffix, cut)
|
|
await fire(ctx, sessionAgent(cold), 1, 1)
|
|
// The supplied suffix is already hydrated; later lifecycle records may still be read.
|
|
expect(lookup.mock.calls.every(([seq]) => seq >= events.length)).toBe(true)
|
|
expect(snapshot).not.toHaveBeenCalled()
|
|
expect(own).not.toHaveBeenCalled()
|
|
lookup.mockRestore()
|
|
snapshot.mockRestore()
|
|
own.mockRestore()
|
|
expect(contextTexts(cold).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]')
|
|
})
|
|
|
|
it('rebuilds an old checkpoint from the full journal and freezes the unit on unload', async () => {
|
|
const { ctx, fiber } = await mount()
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
const session = Session.create(SessionId('browser-old-row'))
|
|
openMessageTurn(session, 1, 'Asia/Shanghai')
|
|
const rows = ctx.sessionProjections.checkpoint(session)
|
|
const oldRows = {
|
|
...rows,
|
|
timeContext: {
|
|
ver: 2,
|
|
seq: rows.timeContext!.seq,
|
|
val: { lastMessageTime: BASE, lastInjectionTime: null, lastTurnInjectionTime: null },
|
|
},
|
|
}
|
|
const cold = Session.create(SessionId('browser-old-cold'), session.snapshotEvents())
|
|
ctx.sessionProjections.hydrate(cold, oldRows, cold.snapshotEvents(), SessionLogOffset(0))
|
|
expect(ctx.sessionProjections.checkpoint(cold).timeContext).toEqual({
|
|
ver: 3, seq: cold.seq - 1, val: rows.timeContext!.val,
|
|
})
|
|
const row = ctx.sessionProjections.checkpoint(cold).timeContext
|
|
await fiber.dispose()
|
|
openMessageTurn(cold, 2, 'UTC')
|
|
// The released unit keeps the hydrated row and folds no event after the unload.
|
|
expect(ctx.sessionProjections.checkpoint(cold)).toHaveProperty('timeContext', row)
|
|
expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('browser-after-unload')))).not.toHaveProperty('timeContext')
|
|
})
|
|
|
|
it('retains reported invalid zones without validating rejected or throttled requests', async () => {
|
|
const { ctx } = await mount({ refreshIntervalMs: 1000 })
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
const session = Session.create(SessionId('browser-refusal-timing'))
|
|
const agent = sessionAgent(session)
|
|
openMessageTurn(session, 1, 'UTC')
|
|
await fire(ctx, agent, 1, 1)
|
|
session.append('user/message', createUserMessage({
|
|
content: [],
|
|
source: { kind: 'user', rpcId: 'invalid', clientTimeZone: '+08:00' } as never,
|
|
}), { surfaceOp: 'append' })
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
|
browserTimeZones: ['UTC', '+08:00'],
|
|
})
|
|
vi.setSystemTime(BASE + 500)
|
|
await expect(fire(ctx, agent, 1, 2)).resolves.toBeUndefined()
|
|
vi.setSystemTime(BASE + 1000)
|
|
const reject = ctx.on('agent/pre-step', () => Promise.resolve({ kind: 'reject' as const }))
|
|
await expect(fire(ctx, agent, 1, 2)).resolves.toBeUndefined()
|
|
reject()
|
|
await expect(fire(ctx, agent, 1, 2)).rejects.toThrow(
|
|
'browser time zone must be canonical UTC or IANA Area/Location: "+08:00"',
|
|
)
|
|
expect(contextTexts(session)).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps the historical requested-turn fallback and resets distinct zones on the next turn', async () => {
|
|
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
const session = Session.create(SessionId('browser-historical-turn'))
|
|
session.append('user/message', createUserMessage({
|
|
content: [],
|
|
source: { kind: 'user', rpcId: 'before-turn', clientTimeZone: 'Europe/London' } as never,
|
|
}), { surfaceOp: 'append' })
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
|
browserTurn: null, browserTimeZones: [],
|
|
})
|
|
openMessageTurn(session, 1, 'Asia/Shanghai')
|
|
session.append('user/message', createUserMessage({
|
|
content: [],
|
|
source: { kind: 'user', rpcId: 'duplicate', clientTimeZone: 'Asia/Shanghai' } as never,
|
|
}), { surfaceOp: 'append' })
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
|
browserTurn: 1, browserTimeZones: ['Asia/Shanghai'],
|
|
})
|
|
openMessageTurn(session, 2, 'America/New_York')
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
|
browserTurn: 2, browserTimeZones: ['America/New_York'],
|
|
})
|
|
await fire(ctx, sessionAgent(session), 1, 1)
|
|
expect(contextTexts(session).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]')
|
|
})
|
|
|
|
it('clears the open-turn injection time at the next turn start', async () => {
|
|
const { ctx } = await mount()
|
|
const session = Session.create(SessionId('same-turn'))
|
|
session.append('turn/start', { turn: 1 })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'reading' }],
|
|
source: { kind: 'time-context' },
|
|
}), { surfaceOp: 'append' })
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toHaveProperty('lastTurnInjectionTime', expect.any(Number))
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
session.append('turn/start', { turn: 2 })
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
|
lastTurnInjectionTime: null,
|
|
})
|
|
})
|
|
|
|
it('keeps the open-turn injection time null when turn/end arrives first', async () => {
|
|
const { ctx } = await mount()
|
|
const session = Session.create(SessionId('end-without-start'))
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
|
lastTurnInjectionTime: null,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('real agent-loop request history', () => {
|
|
it.each([
|
|
['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) => {
|
|
if (mode === 'throws') throw new Error('later pre-step failure')
|
|
subject.cancel({ kind: 'user' })
|
|
return next()
|
|
})
|
|
const agent = await ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
|
|
|
|
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
|
|
await agent.whenIdle()
|
|
|
|
expect(contextTexts(agent.session)).toHaveLength(0)
|
|
expect(adapter.requests).toHaveLength(0)
|
|
expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false)
|
|
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, { refreshIntervalMs: 0 })
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'tick',
|
|
description: 'advance fake time',
|
|
parameters: {},
|
|
async execute() {
|
|
vi.setSystemTime(BASE + 61_000)
|
|
return [{ type: 'text' as const, text: 'advanced' }]
|
|
},
|
|
}))
|
|
const agent = await ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
|
|
|
|
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
|
|
await agent.whenIdle()
|
|
|
|
expect(adapter.requests).toHaveLength(2)
|
|
const contexts = agent.session.snapshotEvents().filter(
|
|
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
|
|
const starts = agent.session.snapshotEvents().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).toBeGreaterThan(starts[index]!.seq)
|
|
}
|
|
expect(contexts.every(event => event.data.source.kind === '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: 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).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).toBeUndefined()
|
|
expect(request.messages[0]?.role).toBe('system')
|
|
expect(JSON.stringify(request.messages[0])).not.toContain('Time sampled while preparing')
|
|
}
|
|
const systemNodes = agent.session.snapshotEvents().filter(event => event.type === 'system/message')
|
|
expect(JSON.stringify(systemNodes)).not.toContain('Time sampled while preparing')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
})
|
|
|
|
describe('real Loader export path', () => {
|
|
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(['agents', 'sessionProjections'])
|
|
expect(unwrapped.Config).toBeDefined()
|
|
expect(typeof unwrapped.apply).toBe('function')
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
|
|
await ctx.plugin(plugin)
|
|
const session = Session.create(SessionId('loader'))
|
|
openMessageTurn(session, 1)
|
|
await fire(ctx, sessionAgent(session), 1, 1)
|
|
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
|
|
})
|
|
})
|