fix(time-context): validate restored readings
This commit is contained in:
@@ -196,6 +196,8 @@ export interface LaunchOptions {
|
|||||||
paceMs?: number
|
paceMs?: number
|
||||||
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
|
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
|
||||||
replayContextWindow?: number
|
replayContextWindow?: number
|
||||||
|
/** Caller-owned keyless adapter for a fixture that must derive its response at stream time. */
|
||||||
|
fixtureAdapter?: LlmAdapter
|
||||||
/**
|
/**
|
||||||
* Tool presentation mode patched onto the shipped `tools` row (`code`
|
* Tool presentation mode patched onto the shipped `tools` row (`code`
|
||||||
* collapses the wire to run_code + the SDK prompt section). Omit for the
|
* collapses the wire to run_code + the SDK prompt section). Omit for the
|
||||||
@@ -264,6 +266,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
|||||||
if (mode === 'record' && options.deepSeekMissingCredential === true) {
|
if (mode === 'record' && options.deepSeekMissingCredential === true) {
|
||||||
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
|
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
|
||||||
}
|
}
|
||||||
|
if (options.fixtureAdapter !== undefined
|
||||||
|
&& (mode === 'record' || options.replayFixture !== undefined
|
||||||
|
|| options.deepSeekMissingCredential === true)) {
|
||||||
|
throw new Error('fixtureAdapter is exclusive with record, replayFixture, and deepSeekMissingCredential')
|
||||||
|
}
|
||||||
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
|
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
|
||||||
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
|
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
|
||||||
let credentialEnvironmentRestored = false
|
let credentialEnvironmentRestored = false
|
||||||
@@ -442,8 +449,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
|||||||
// issues no model calls, and one that slipped in must not pass quietly.
|
// issues no model calls, and one that slipped in must not pass quietly.
|
||||||
ctx.effect(() => ctx.llm.registerAdapter(
|
ctx.effect(() => ctx.llm.registerAdapter(
|
||||||
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
||||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
options.fixtureAdapter ?? new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
||||||
), 'web e2e scaffold: route-only adapter')
|
), 'web e2e scaffold: fixture adapter')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// one-second owner path queues a best-effort followup, commits dispatch, and
|
// one-second owner path queues a best-effort followup, commits dispatch, and
|
||||||
// renders the Host's durability-gated reminder sidecar. A separate browser
|
// renders the Host's durability-gated reminder sidecar. A separate browser
|
||||||
// scenario drives local at through the real zone wire and model tool call.
|
// scenario drives local at through the real zone wire and model tool call.
|
||||||
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
import { mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -11,8 +11,8 @@ import type { Browser, Page } from 'playwright'
|
|||||||
import { chromium } from 'playwright'
|
import { chromium } from 'playwright'
|
||||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { Session } from '@deepseek-ai/dsh-session'
|
import type { Session } from '@deepseek-ai/dsh-session'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
@@ -44,6 +44,50 @@ interface CreatedScheduleView {
|
|||||||
deliveryMode: 'session-local'
|
deliveryMode: 'session-local'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deterministic model boundary that selects local at relative to its actual first request. */
|
||||||
|
class BrowserZoneAtAdapter extends LlmAdapter {
|
||||||
|
readonly requests: GenerateOptions[] = []
|
||||||
|
scheduledAt: string | undefined
|
||||||
|
|
||||||
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||||
|
return Promise.resolve({ provider, id: model, name: model, contextWindow: 128_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||||
|
this.requests.push(options)
|
||||||
|
if (this.requests.length === 1) {
|
||||||
|
const target = Math.ceil((Date.now() + 10_000) / 1_000) * 1_000
|
||||||
|
const scheduledAt = new Date(target).toISOString()
|
||||||
|
this.scheduledAt = scheduledAt
|
||||||
|
const args = JSON.stringify({
|
||||||
|
prompt: AT_PROMPT,
|
||||||
|
at: { date: scheduledAt.slice(0, 10), time: scheduledAt.slice(11, 19) },
|
||||||
|
})
|
||||||
|
const callId = CallId('schedule-at-wire-call')
|
||||||
|
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
||||||
|
yield {
|
||||||
|
type: 'tool-call-delta', index: 0, id: callId,
|
||||||
|
name: 'schedule_create', argumentsDelta: args,
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: 'block-end', index: 0,
|
||||||
|
block: { type: 'tool-call', id: callId, name: 'schedule_create', arguments: args },
|
||||||
|
}
|
||||||
|
yield { type: 'usage', usage: { inputTokens: 256, outputTokens: 32 } }
|
||||||
|
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = this.requests.length === 2
|
||||||
|
? 'The zone-aware reminder is scheduled.'
|
||||||
|
: 'The zone-aware reminder is due.'
|
||||||
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||||
|
yield { type: 'text-delta', index: 0, text }
|
||||||
|
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
||||||
|
yield { type: 'usage', usage: { inputTokens: 128, outputTokens: 16 } }
|
||||||
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Wait for one in-process lifecycle fact without using test-scoped expect.poll in beforeAll. */
|
/** Wait for one in-process lifecycle fact without using test-scoped expect.poll in beforeAll. */
|
||||||
async function waitForFact(read: () => boolean, timeoutMs: number): Promise<void> {
|
async function waitForFact(read: () => boolean, timeoutMs: number): Promise<void> {
|
||||||
const deadline = Date.now() + timeoutMs
|
const deadline = Date.now() + timeoutMs
|
||||||
@@ -187,59 +231,12 @@ describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', ()
|
|||||||
let browser: Browser
|
let browser: Browser
|
||||||
let page: Page
|
let page: Page
|
||||||
let tripwire: ReturnType<typeof watchConsole>
|
let tripwire: ReturnType<typeof watchConsole>
|
||||||
let replayDir: string
|
const adapter = new BrowserZoneAtAdapter()
|
||||||
let scheduledAt: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-schedule-at-wire-replay-'))
|
|
||||||
const replayOverride = join(replayDir, 'replay.override.json')
|
|
||||||
const target = Math.ceil((Date.now() + 30_000) / 1_000) * 1_000
|
|
||||||
scheduledAt = new Date(target).toISOString()
|
|
||||||
const args = JSON.stringify({
|
|
||||||
prompt: AT_PROMPT,
|
|
||||||
at: { date: scheduledAt.slice(0, 10), time: scheduledAt.slice(11, 19) },
|
|
||||||
})
|
|
||||||
const callId = CallId('schedule-at-wire-call')
|
|
||||||
const toolCall: ReplayEntry = {
|
|
||||||
kind: 'chunks',
|
|
||||||
chunks: [
|
|
||||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
||||||
{
|
|
||||||
type: 'tool-call-delta',
|
|
||||||
index: 0,
|
|
||||||
id: callId,
|
|
||||||
name: 'schedule_create',
|
|
||||||
argumentsDelta: args,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'block-end',
|
|
||||||
index: 0,
|
|
||||||
block: { type: 'tool-call', id: callId, name: 'schedule_create', arguments: args },
|
|
||||||
},
|
|
||||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 32 } },
|
|
||||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const textReply = (text: string): ReplayEntry => ({
|
|
||||||
kind: 'chunks',
|
|
||||||
chunks: [
|
|
||||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
|
||||||
{ type: 'text-delta', index: 0, text },
|
|
||||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
|
||||||
{ type: 'usage', usage: { inputTokens: 128, outputTokens: 16 } },
|
|
||||||
{ type: 'finish', reason: { kind: 'stop' } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
await writeFile(replayOverride, JSON.stringify([
|
|
||||||
toolCall,
|
|
||||||
textReply('The zone-aware reminder is scheduled.'),
|
|
||||||
textReply('The zone-aware reminder is due.'),
|
|
||||||
] satisfies ReplayEntry[]))
|
|
||||||
scaffold = await launchWebScaffold({
|
scaffold = await launchWebScaffold({
|
||||||
extraOverlayPath: OVERLAY,
|
extraOverlayPath: OVERLAY,
|
||||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
fixtureAdapter: adapter,
|
||||||
replayOverride,
|
|
||||||
replayContextWindow: 128_000,
|
|
||||||
})
|
})
|
||||||
browser = await chromium.launch()
|
browser = await chromium.launch()
|
||||||
page = await browser.newPage({
|
page = await browser.newPage({
|
||||||
@@ -258,7 +255,6 @@ describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', ()
|
|||||||
const failures: unknown[] = []
|
const failures: unknown[] = []
|
||||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||||
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
|
||||||
if (failures.length === 1) throw failures[0]
|
if (failures.length === 1) throw failures[0]
|
||||||
if (failures.length > 1) throw new AggregateError(failures, 'Schedule at wire evidence teardown failed')
|
if (failures.length > 1) throw new AggregateError(failures, 'Schedule at wire evidence teardown failed')
|
||||||
})
|
})
|
||||||
@@ -300,6 +296,14 @@ describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', ()
|
|||||||
expect(timeContextIndex).toBeGreaterThanOrEqual(0)
|
expect(timeContextIndex).toBeGreaterThanOrEqual(0)
|
||||||
expect(toolCallIndex).toBeGreaterThan(timeContextIndex)
|
expect(toolCallIndex).toBeGreaterThan(timeContextIndex)
|
||||||
|
|
||||||
|
const firstRequest = adapter.requests[0]
|
||||||
|
if (firstRequest === undefined) throw new Error('model did not receive the browser prompt')
|
||||||
|
expect(JSON.stringify(firstRequest.messages)).toContain('Session time zone: UTC.')
|
||||||
|
expect(JSON.stringify(firstRequest.messages)).toContain('Client time zone for this request: UTC.')
|
||||||
|
expect(firstRequest.tools?.some(tool => tool.name === 'schedule_create')).toBe(true)
|
||||||
|
|
||||||
|
const scheduledAt = adapter.scheduledAt
|
||||||
|
if (scheduledAt === undefined) throw new Error('model did not choose a local at target')
|
||||||
const created = agent.session.events.find(event =>
|
const created = agent.session.events.find(event =>
|
||||||
event.type === 'schedule/change'
|
event.type === 'schedule/change'
|
||||||
&& event.data.operation === 'create'
|
&& event.data.operation === 'create'
|
||||||
@@ -312,8 +316,9 @@ describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', ()
|
|||||||
await waitForFact(() => agent.session.events.some(event =>
|
await waitForFact(() => agent.session.events.some(event =>
|
||||||
event.type === 'schedule/change'
|
event.type === 'schedule/change'
|
||||||
&& event.data.operation === 'dispatch'
|
&& event.data.operation === 'dispatch'
|
||||||
&& event.data.id === scheduleId), 45_000)
|
&& event.data.id === scheduleId), 20_000)
|
||||||
await agent.whenIdle()
|
await agent.whenIdle()
|
||||||
|
expect(adapter.requests).toHaveLength(3)
|
||||||
await expect(scaffold.ctx.sessions.flush(agent.session)).resolves.toBe(true)
|
await expect(scaffold.ctx.sessions.flush(agent.session)).resolves.toBe(true)
|
||||||
|
|
||||||
const history = await scaffold.ctx.apiProxy.sessions.history({
|
const history = await scaffold.ctx.apiProxy.sessions.history({
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ function validateSession(session: Session, fail: InvariantFailure): void {
|
|||||||
/** Install validation for loaded and newly appended context readings. */
|
/** Install validation for loaded and newly appended context readings. */
|
||||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||||
|
ctx.on('session/created', (session) => { validateSession(session, fail) }, { global: true })
|
||||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||||
if (eventName !== 'session/event') return
|
if (eventName !== 'session/event') return
|
||||||
const [session, event] = args as [Session, SessionEvent]
|
const [session, event] = args as [Session, SessionEvent]
|
||||||
|
|||||||
@@ -189,6 +189,38 @@ describe('time-context invariants', () => {
|
|||||||
}).toThrow(/Session time zone cannot format its durable timestamp/)
|
}).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 () => {
|
it('rejects a time-context source that duplicates request authority', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const base = event(reading())
|
const base = event(reading())
|
||||||
|
|||||||
Reference in New Issue
Block a user