diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 064af060bd..917c2937ee 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -214,21 +214,19 @@ export function apply(ctx: Context, config: Config): () => void { const formatter = sessionTimeZone === undefined ? fallbackFormatter : formatterFor(sessionTimeZone) + const text = renderText( + now, + turn, + step, + previous, + formatter, + displayTimeZone, + sessionTimeZone, + requestMessages(agent, turn, messages), + ) return createUserMessage({ - content: [{ - type: 'text', - text: renderText( - now, - turn, - step, - previous, - formatter, - displayTimeZone, - sessionTimeZone, - requestMessages(agent, turn, messages), - ), - }], - source: { kind: 'plugin', plugin: name }, + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, }) } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 0fdd953508..79917ad6bf 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -82,8 +82,24 @@ function validateReading( if (turn !== expected.turn || step !== expected.step) { fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`) } - if (Object.keys(event.data.source).length !== 2) { - fail('time-context source must not duplicate request authority') + const source = event.data.source + /* v8 ignore next 2 -- replay and dispatch callers select this exact package-owned source before validation. */ + if (source.kind !== 'plugin' || source.plugin !== SOURCE_NAME) { + fail('time-context source must retain package ownership') + } + const sections: unknown = 'sections' in source ? source.sections : undefined + const section: unknown = Array.isArray(sections) ? sections[0] : undefined + if (Object.keys(source).length !== 4 + || source.form !== 'snapshot' + || !Array.isArray(sections) + || sections.length !== 1 + || typeof section !== 'object' + || section === null + || !('name' in section) + || section.name !== SOURCE_NAME + || !('text' in section) + || section.text !== block.text) { + fail('time-context source must carry only the exact snapshot text, not request authority') } const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.` const expectedAuthority = renderTimeZoneContext( diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index e303553e38..926af82227 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -32,6 +32,8 @@ function event( ? { kind: 'plugin', plugin, + form: 'snapshot', + sections: [{ name: plugin, text }], } : { kind: 'plugin', plugin }, }), @@ -77,6 +79,8 @@ function appendReading(session: Session, text: string): void { source: { kind: 'plugin', plugin: 'time-context', + form: 'snapshot', + sections: [{ name: 'time-context', text }], }, }), { surfaceOp: 'append' }) } @@ -148,7 +152,22 @@ describe('time-context invariants', () => { } expect(() => { ctx.emit('session/event', preparing(1, 1), duplicate) - }).toThrow(/must not duplicate request authority/) + }).toThrow(/must carry only the exact snapshot text/) + }) + + it('rejects package-owned provenance without snapshot sections', async () => { + const ctx = await setup() + const base = event(reading()) + const unformed: SessionEvent<'user/message'> = { + ...base, + data: { + ...base.data, + source: { kind: 'plugin', plugin: 'time-context' }, + }, + } + expect(() => { + ctx.emit('session/event', preparing(1, 1), unformed) + }).toThrow(/must carry only the exact snapshot text/) }) it('validates each existing reading against its preceding durable prefix', async () => { diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index d0a57907b4..93848c3954 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -222,16 +222,23 @@ interface AtTimeZoneContext { function isTimeContextReading(event: SessionEvent): boolean { if (event.type !== 'user/message') return false const source = event.data.source + if (source.kind !== 'plugin' + || source.plugin !== 'time-context' + || Object.keys(source).length !== 4 + || source.form !== 'snapshot') return false const [block] = event.data.content + const sections: unknown = source.sections + const section: unknown = Array.isArray(sections) ? sections[0] : undefined return event.data.content.length === 1 && block?.type === 'text' - && source.kind === 'plugin' - && source.plugin === 'time-context' - && Object.keys(source).length === 4 - && source.form === 'snapshot' - && source.sections.length === 1 - && source.sections[0]?.name === 'time-context' - && source.sections[0].text === block.text + && Array.isArray(sections) + && sections.length === 1 + && typeof section === 'object' + && section !== null + && 'name' in section + && section.name === 'time-context' + && 'text' in section + && section.text === block.text } /** Derive request zones only while the current open turn contains a time-context reading. */