From edb23439f64d4804b3798ba4fbaea935a7ec4b5e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 7 Aug 2026 23:47:51 +0800 Subject: [PATCH] fix(time-context): validate durable zone authority --- apps/web/tests/schedule-after.e2e.ts | 193 ++++++++++++++---- docs/config-catalog.md | 2 +- packages/context/time-context/src/index.ts | 27 +-- .../context/time-context/src/invariant.ts | 17 ++ .../context/time-context/src/request-zone.ts | 2 + .../context/time-context/src/timestamp.ts | 37 ++++ .../time-context/tests/invariant.spec.ts | 43 +++- .../time-context/tests/request-zone.spec.ts | 6 +- .../time-context/tests/time-context.spec.ts | 2 +- .../tool-schedule/tests/tools.spec.ts | 8 +- 10 files changed, 261 insertions(+), 76 deletions(-) create mode 100644 packages/context/time-context/src/timestamp.ts diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 714d9eec1b..95b891a141 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,10 +1,9 @@ // Keyless assembled-browser evidence for the opt-in Schedule overlay. A real // root Agent receives schedule_create through the complete tool pipeline; the -// one-second owner path and a short explicit at target each queue a best-effort -// followup, commit dispatch, and render the Host's durability-gated reminder -// sidecar. No model fixture is installed: later prompt failure cannot retract -// either receipt. -import { mkdtemp, realpath, rm } from 'node:fs/promises' +// one-second owner path queues a best-effort followup, commits dispatch, and +// renders the Host's durability-gated reminder sidecar. A separate browser +// scenario drives local at through the real zone wire and model tool call. +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -13,6 +12,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -20,7 +20,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' import { ScheduleId, createAfterScheduleRecord, @@ -177,58 +177,167 @@ describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () expect(tripwire.warnings).toEqual([]) }, 60_000) - it('renders a short explicit at reminder through the same durable Web path', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at')) - await waitForFact(() => agentHandle.agent.status === 'idle', 10_000) - const scheduledAt = new Date(Date.now() + 3_000).toISOString() - const created = await scaffold.ctx.tools.execute({ - signal: AbortSignal.timeout(10_000), - callId: CallId('schedule-at-create'), - name: 'schedule_create', - arguments: { prompt: AT_PROMPT, at: scheduledAt }, - agent: agentHandle.agent, - }) - expect(created.isError).toBe(false) - if (created.isError) throw new Error(created.error.message) - const value = created.value as unknown as CreatedScheduleView - expect(value).toMatchObject({ - kind: 'at', - scheduledAt, - deliveryMode: 'session-local', - }) - expect(value.id.length).toBeGreaterThan(0) + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['at-receipt.expected.md', 'receipt.expected.md']) + }) +}) - await waitForFact(() => agentHandle.agent.session.events.some(event => +describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let replayDir: string + let scheduledAt: string + + 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({ + extraOverlayPath: OVERLAY, + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + replayContextWindow: 128_000, + }) + browser = await chromium.launch() + page = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, + locale: 'en-US', + timezoneId: SESSION_TIME_ZONE, + }) + await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'schedule-at-wire-e2e') + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.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 new AggregateError(failures, 'Schedule at wire evidence teardown failed') + }) + + it('carries the browser zone through prompt context, local at, and the durable receipt', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at-wire')) + const composer = page.locator('textarea:enabled').last() + await composer.fill('Schedule the release-window reminder in my local time.') + const settled = scaffold.whenTurnSettled(60_000) + await page.getByRole('button', { name: 'Send message', exact: true }).click() + const sessionId = await settled + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('browser-created Schedule Session has no live Agent') + expect(agent.session.header.timeZone).toBe(SESSION_TIME_ZONE) + + const request = agent.session.events.find(event => + event.type === 'user/message' + && event.data.source.kind === 'user' + && event.data.content.some(block => block.type === 'text' + && block.text === 'Schedule the release-window reminder in my local time.')) + if (request?.type !== 'user/message' || request.data.source.kind !== 'user') { + throw new Error('missing browser user-rpc message') + } + expect(request.data.source).toMatchObject({ + kind: 'user', + clientTimeZone: SESSION_TIME_ZONE, + }) + expect(typeof (request.data.source as { rpcId?: unknown }).rpcId).toBe('string') + + const timeContextIndex = agent.session.events.findIndex(event => + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'time-context' + && event.data.content.some(block => block.type === 'text' + && block.text.includes('Session time zone: UTC.') + && block.text.includes('Client time zone for this request: UTC.'))) + const toolCallIndex = agent.session.events.findIndex(event => + event.type === 'tool/call' && event.data.name === 'schedule_create') + expect(timeContextIndex).toBeGreaterThanOrEqual(0) + expect(toolCallIndex).toBeGreaterThan(timeContextIndex) + + const created = agent.session.events.find(event => event.type === 'schedule/change' - && (event.data as { operation?: unknown; id?: unknown }).operation === 'dispatch' - && (event.data as { id?: unknown }).id === value.id), 15_000) - await expect(scaffold.ctx.sessions.flush(agentHandle.agent.session)).resolves.toBe(true) + && event.data.operation === 'create' + && event.data.schedule.kind === 'at' + && event.data.schedule.scheduledAt === scheduledAt) + if (created?.type !== 'schedule/change' || created.data.operation !== 'create') { + throw new Error('local at tool call did not create its durable record') + } + const scheduleId = created.data.schedule.id + await waitForFact(() => agent.session.events.some(event => + event.type === 'schedule/change' + && event.data.operation === 'dispatch' + && event.data.id === scheduleId), 45_000) + await agent.whenIdle() + await expect(scaffold.ctx.sessions.flush(agent.session)).resolves.toBe(true) + const history = await scaffold.ctx.apiProxy.sessions.history({ - rpcId: RpcId('schedule-at-history'), payload: { sessionId: agentHandle.agent.id }, + rpcId: RpcId('schedule-at-wire-history'), + payload: { sessionId }, }) if (!history.result.ok) throw new Error(history.result.error.message) expect(history.result.value.events?.find(entry => entry.event.type === 'schedule/change' - && (entry.event.data as { operation?: unknown; id?: unknown }).operation === 'dispatch' - && (entry.event.data as { id?: unknown }).id === value.id)?.view).toMatchObject({ - for: 'event', presentationKey: 'schedule/reminder', + && entry.event.data.operation === 'dispatch' + && entry.event.data.id === scheduleId)?.view).toMatchObject({ + for: 'event', + view: { scheduleId, prompt: AT_PROMPT, occurrenceAt: scheduledAt }, }) const receipt = page.locator(AT_RECEIPT_SELECTOR) - await receipt.waitFor({ timeout: 15_000 }) - expect(await receipt.getByText(AT_PROMPT, { exact: true }).count()).toBe(1) - expect(await receipt.getByText('Delivered in this session only', { exact: true }).count()).toBe(1) + await receipt.waitFor({ timeout: 20_000 }) const snapshot = (await captureStableAria(page, AT_RECEIPT_SELECTOR, scaffold.workspaceCwd)) - .split(value.id).join('{{scheduleId}}') + .split(scheduleId).join('{{scheduleId}}') .replace(/\d{4}-\d{2}-\d{2}T(?:\d{2}:\d{2}:\d{2}\.\d{3}|\{\{clock\}\})Z/gu, '{{occurrenceAt}}') await compareOrRefreshGolden(AT_RECEIPT_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) - - it('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['at-receipt.expected.md', 'receipt.expected.md']) - }) }) describe.skipIf(MODE === 'record')('web e2e: Schedule restart, fork, and cold history', () => { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5e1236d314..5841e6255e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1884,7 +1884,7 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:28`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:29`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 917c2937ee..955f3f41e0 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -14,6 +14,7 @@ import { deriveClientTimeZoneContext, renderTimeZoneContext, } from './request-zone.ts' +import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' export type { ClientTimeZoneContext } from './request-zone.ts' export { deriveClientTimeZoneContext } from './request-zone.ts' @@ -38,17 +39,6 @@ export const Config: z = z.object({ refreshIntervalMs: z.number(), }) -type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' - -/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */ -function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string { - const parts = Object.fromEntries( - formatter.formatToParts(now).map(part => [part.type, part.value]), - ) as Record - const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3) - return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]` -} - /** Format a non-negative elapsed millisecond count as compact whole-second units. */ function formatDuration(elapsedMs: number): string { let seconds = Math.floor(Math.max(0, elapsedMs) / 1000) @@ -160,20 +150,9 @@ export function apply(ctx: Context, config: Config): () => void { const timeZone = config.timeZone const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - const createFormatter = (selectedTimeZone?: string): Intl.DateTimeFormat => new Intl.DateTimeFormat('en-US', { - ...(selectedTimeZone === undefined ? {} : { timeZone: selectedTimeZone }), - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - timeZoneName: 'longOffset', - }) let fallbackFormatter: Intl.DateTimeFormat try { - fallbackFormatter = createFormatter(timeZone) + fallbackFormatter = createTimestampFormatter(timeZone) } catch (error: unknown) { const message = timeZone === undefined ? 'time-context: failed to resolve the system time zone' @@ -190,7 +169,7 @@ export function apply(ctx: Context, config: Config): () => void { if (existing !== undefined) return existing let created: Intl.DateTimeFormat try { - created = createFormatter(selectedTimeZone) + created = createTimestampFormatter(selectedTimeZone) } catch (error: unknown) { throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error }) } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 26259f6495..49007a8eec 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -4,6 +4,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts' +import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' const SOURCE_NAME = 'time-context' @@ -140,6 +141,22 @@ function validateReading( || event.time < renderedTime) { fail('time-context rendered timestamp must parse and not postdate its durable event') } + const sessionTimeZone = session.header.timeZone + if (sessionTimeZone !== undefined) { + let expectedTimestamp: string + try { + expectedTimestamp = formatTimestamp( + renderedTime, + createTimestampFormatter(sessionTimeZone), + sessionTimeZone, + ) + } catch (error: unknown) { + fail(`time-context Session time zone cannot format its durable timestamp: ${String(error)}`) + } + if (rendered !== expectedTimestamp) { + fail('time-context rendered timestamp does not match the Session time zone') + } + } } /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ diff --git a/packages/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 11cf255b1b..de65528fd1 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -12,6 +12,8 @@ export type ClientTimeZoneContext = function clientTimeZone(message: UserMessage): string | undefined { const source = message.source return source.kind === 'user' + && 'rpcId' in source + && typeof source.rpcId === 'string' && 'clientTimeZone' in source && typeof source.clientTimeZone === 'string' ? source.clientTimeZone diff --git a/packages/context/time-context/src/timestamp.ts b/packages/context/time-context/src/timestamp.ts new file mode 100644 index 0000000000..3744e3a453 --- /dev/null +++ b/packages/context/time-context/src/timestamp.ts @@ -0,0 +1,37 @@ +/** ISO-shaped time-context timestamp formatting shared by production and replay validation. */ + +type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' + +/** + * Create the exact formatter used by durable time-context readings. + * @param timeZone - Explicit display zone, or `undefined` for the process fallback. + * @returns A formatter with stable numeric local fields and long numeric offset. + */ +export function createTimestampFormatter(timeZone?: string): Intl.DateTimeFormat { + return new Intl.DateTimeFormat('en-US', { + ...(timeZone === undefined ? {} : { timeZone }), + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZoneName: 'longOffset', + }) +} + +/** + * Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. + * @param now - Epoch milliseconds to display. + * @param formatter - Formatter created for `timeZone`. + * @param timeZone - Canonical zone label carried in brackets. + * @returns The durable timestamp text. + */ +export function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string { + const parts = Object.fromEntries( + formatter.formatToParts(now).map(part => [part.type, part.value]), + ) as Record + const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3) + return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]` +} diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 9ea928c192..ea73f3ea27 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -126,7 +126,7 @@ describe('time-context invariants', () => { session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'travel request' }], - source: { kind: 'user', clientTimeZone: 'America/New_York' } as never, + source: { kind: 'user', rpcId: 'travel-request', clientTimeZone: 'America/New_York' } as never, }), { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) @@ -135,7 +135,7 @@ describe('time-context invariants', () => { '1', '1', 'model-visible message', - '2026-07-14T00:00:00+00:00[UTC]', + '2026-07-14T08:00:00+08:00[Asia/Shanghai]', 'Asia/Shanghai', 'America/New_York', ))) @@ -145,11 +145,48 @@ describe('time-context invariants', () => { '1', '1', 'model-visible message', - '2026-07-14T00:00:00+00:00[UTC]', + '2026-07-14T08:00:00+08:00[Asia/Shanghai]', 'Asia/Shanghai', 'Asia/Shanghai', ))) }).toThrow(/does not match the Session and current request zones/) + expect(() => { + ctx.emit('session/event', session, event(reading( + '1', + '1', + 'model-visible message', + '2026-07-14T00:00:00+00:00[UTC]', + 'Asia/Shanghai', + 'America/New_York', + ))) + }).toThrow(/rendered timestamp does not match the Session time zone/) + }) + + it('rejects a durable reading whose Session zone cannot format the timestamp', async () => { + const ctx = await setup() + const id = SessionId('time-invariant-invalid-zone') + const session = Session.create(id, [], { + version: 0, + id, + createdAt: SECOND, + timeZone: 'Invalid/Zone', + }) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'invalid zone request' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + + expect(() => { + ctx.emit('session/event', session, event(reading( + '1', + '1', + 'model-visible message', + '2026-07-14T00:00:00+00:00[UTC]', + 'Invalid/Zone', + ))) + }).toThrow(/Session time zone cannot format its durable timestamp/) }) it('rejects a time-context source that duplicates request authority', async () => { diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts index 3030c3a52f..85bfae64db 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -11,7 +11,7 @@ function request(clientTimeZone?: unknown) { content: [{ type: 'text', text: 'request' }], source: clientTimeZone === undefined ? { kind: 'user' } - : { kind: 'user', clientTimeZone } as never, + : { kind: 'user', rpcId: 'request-zone', clientTimeZone } as never, }) } @@ -27,6 +27,10 @@ describe('request-zone derivation', () => { source: { kind: 'plugin', plugin: 'fixture' }, }) expect(deriveClientTimeZoneContext([plugin, request(), request(1)])).toEqual({ kind: 'missing' }) + expect(deriveClientTimeZoneContext([createUserMessage({ + content: [], + source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + })])).toEqual({ kind: 'missing' }) expect(deriveClientTimeZoneContext([ request('Asia/Shanghai'), request('Asia/Shanghai'), diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 753ae5ca9d..a7cf5e8aca 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -104,7 +104,7 @@ async function fire( function rpcMessage(text: string, clientTimeZone: string): UserMessage { return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'user', clientTimeZone } as never, + source: { kind: 'user', rpcId: `rpc-${text}`, clientTimeZone } as never, }) } diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index f51da4859e..2e2563502d 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -95,7 +95,7 @@ function appendRequestContext(agent: Agent, clientTimeZones: readonly string[]): for (const [index, clientTimeZone] of clientTimeZones.entries()) { agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `request ${index + 1}` }], - source: { kind: 'user', clientTimeZone } as never, + source: { kind: 'user', rpcId: `request-zone-${String(index + 1)}`, clientTimeZone } as never, }), { surfaceOp: 'append' }) } const text = 'time context' @@ -273,7 +273,7 @@ describe('Schedule tool protocol', () => { unmarked.agent.session.append('step/start', { turn: 1, step: 1 }) unmarked.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'request without time reading' }], - source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + source: { kind: 'user', rpcId: 'unmarked-request', clientTimeZone: 'Asia/Shanghai' } as never, }), { surfaceOp: 'append' }) expect(value(await execute(unmarked, 'schedule_create', { prompt: 'unmarked', at: { date: '2026-08-06', time: '09:00:00' }, @@ -365,7 +365,7 @@ describe('Schedule tool protocol', () => { test.agent.session.append('step/start', { turn: 1, step: 1 }) test.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'request' }], - source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + source: { kind: 'user', rpcId: 'array-like-request', clientTimeZone: 'Asia/Shanghai' } as never, }), { surfaceOp: 'append' }) const text = 'time context' test.agent.session.append('user/message', createUserMessage({ @@ -400,7 +400,7 @@ describe('Schedule tool protocol', () => { test.agent.session.append('step/start', { turn: 1, step: 1 }) test.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'request' }], - source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + source: { kind: 'user', rpcId: 'malformed-marker-request', clientTimeZone: 'Asia/Shanghai' } as never, }), { surfaceOp: 'append' }) test.agent.session.append('user/message', createUserMessage({ content: [block as never],