Explorar o código

fix(time-context): validate durable zone authority

pku-xht hai 2 meses
pai
achega
edb23439f6

+ 149 - 40
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,
+  it('keeps the fixture inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['at-receipt.expected.md', 'receipt.expected.md'])
+  })
+})
+
+describe.skipIf(MODE === 'record')('web e2e: browser-zone local at reminder', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  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) },
     })
-    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',
+    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,
     })
-    expect(value.id.length).toBeGreaterThan(0)
+    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)
 
-    await waitForFact(() => agentHandle.agent.session.events.some(event =>
+  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', () => {

+ 1 - 1
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`
 

+ 3 - 24
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<Config> = 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<TimestampPart, string>
-  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 })
     }

+ 17 - 0
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 */

+ 2 - 0
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

+ 37 - 0
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<TimestampPart, string>
+  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}]`
+}

+ 40 - 3
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 () => {

+ 5 - 1
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'),

+ 1 - 1
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,
   })
 }
 

+ 4 - 4
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],