diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 999fc1aa40..df1184ffed 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -428,7 +428,19 @@ async function executeSessionTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await ctx.sessionQuery.traceSession(sessionId) + let trace: SessionLineageTrace + try { + trace = await ctx.sessionQuery.traceSession(sessionId) + } catch (error: unknown) { + exec.signal.throwIfAborted() + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') { + throw new SessionQueryError( + 'session lineage is invalid', + 'SESSION_QUERY_INVALID_LINEAGE', + ) + } + throw error + } exec.signal.throwIfAborted() assertObservedTargetAuthorized(caller, sessionId, trace.target.header) @@ -579,21 +591,31 @@ function timestampRange( to: string | undefined, ): { from?: number; to?: number } | undefined { if (from === undefined && to === undefined) return undefined - const fromMs = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) - const toMs = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) - if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) { + const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) + const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) + if ( + fromTimestamp !== undefined + && toTimestamp !== undefined + && compareTimestamps(fromTimestamp, toTimestamp) > 0 + ) { throw invalidRange(name, 'from must be less than or equal to to') } return { - ...fromMs === undefined ? {} : { from: fromMs }, - ...toMs === undefined ? {} : { to: toMs }, + ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) }, + ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) }, } } const ISO_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ -function parseIsoTimestamp(name: string, value: string): number { +interface ExactTimestamp { + readonly millisecond: number + /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */ + readonly remainder: string +} + +function parseIsoTimestamp(name: string, value: string): ExactTimestamp { const match = ISO_TIMESTAMP.exec(value) if (match === null) { throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') @@ -614,8 +636,63 @@ function parseIsoTimestamp(name: string, value: string): number { ) { throw invalidRange(name, 'must be a valid ISO 8601 timestamp') } - const timestamp = Date.parse(value) - return timestamp + const fraction = match[7] ?? '' + const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0') + const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}` + + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}` + const timestamp = Date.parse(normalized) + if (!Number.isSafeInteger(timestamp)) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + return { + millisecond: timestamp, + remainder: fraction.slice(3).replace(/0+$/u, ''), + } +} + +function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number { + if (left.millisecond !== right.millisecond) { + return left.millisecond < right.millisecond ? -1 : 1 + } + const length = Math.max(left.remainder.length, right.remainder.length) + for (let index = 0; index < length; index += 1) { + const leftDigit = left.remainder[index] ?? '0' + const rightDigit = right.remainder[index] ?? '0' + if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function timestampLowerBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextUpFinite(timestamp.millisecond) +} + +function timestampUpperBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextDownFinite(timestamp.millisecond + 1) +} + +/** Return the adjacent IEEE-754 value toward positive infinity for a finite input. */ +function nextUpFinite(value: number): number { + if (value === 0) return Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n) + return view.getFloat64(0) +} + +/** Return the adjacent IEEE-754 value toward negative infinity for a finite input. */ +function nextDownFinite(value: number): number { + if (value === 0) return -Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n) + return view.getFloat64(0) } function daysInMonth(year: number, month: number): number { diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index fb85bdc309..fd7c07538b 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -97,4 +97,132 @@ describe('tool-session-query with the real SQLite provider', () => { expect(liveEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n')) .toContain('seq 1') }) + + it('passes finite fractional epoch-millisecond bounds through SQLite comparisons', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-fractional-')) + temporaryDirectories.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + await ctx.plugin(SessionQuerySqlite, { path: join(root, 'session-query.db') }) + await ctx.plugin(ToolSessionQuery) + + const base = Date.parse('2026-07-24T00:00:00.000Z') + const persisted = SessionId('fractional-persisted') + await ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: persisted, + createdAt: base, + cwd: '/work', + }) + await ctx.sessionPersistence.append(persisted, [ + { + type: 'user/message', + seq: 0, + time: base + 123, + data: { + content: [{ type: 'text', text: 'fractional integration needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 1, + time: base + 124, + data: { + content: [{ type: 'text', text: 'fractional integration needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 2, + time: -124, + data: { + content: [{ type: 'text', text: 'pre-epoch fractional needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 3, + time: -123, + data: { + content: [{ type: 'text', text: 'pre-epoch fractional needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + ]) + + const caller = ctx.sessions.create(SessionId('fractional-caller'), { + meta: { createdAt: base + 1_000, cwd: '/work' }, + }) + let call = 0 + const execute = (args: unknown) => ctx.tools.execute({ + name: 'session_event_search', + arguments: args, + callId: CallId(`fractional-integration-${++call}`), + signal: new AbortController().signal, + agent: fakeAgent(caller), + }) + + const lowerBound = await execute({ + session_id: persisted, + query: 'fractional integration needle', + time_from: '2026-07-24T00:00:00.12300001Z', + }) + expect(lowerBound.isError).toBe(false) + const lowerText = lowerBound.content.map(block => block.type === 'text' ? block.text : '').join('\n') + expect(lowerText).toContain('seq 1') + expect(lowerText).not.toContain('seq 0') + + const upperBound = await execute({ + session_id: persisted, + query: 'fractional integration needle', + time_to: '2026-07-24T08:00:00.1239999+08:00', + }) + expect(upperBound.isError).toBe(false) + const upperText = upperBound.content.map(block => block.type === 'text' ? block.text : '').join('\n') + expect(upperText).toContain('seq 0') + expect(upperText).not.toContain('seq 1') + + const emptySameMillisecond = await execute({ + session_id: persisted, + query: 'fractional integration needle', + time_from: '2026-07-24T00:00:00.12300001Z', + time_to: '2026-07-24T08:00:00.1239999+08:00', + }) + expect(emptySameMillisecond.isError).toBe(false) + expect(emptySameMillisecond.content.map(block => block.type === 'text' ? block.text : '').join('\n')) + .toContain('No prior event matches found.') + + const preEpochLower = await execute({ + session_id: persisted, + query: 'pre-epoch fractional needle', + time_from: '1969-12-31T23:59:59.87600001Z', + }) + expect(preEpochLower.isError).toBe(false) + const preEpochLowerText = preEpochLower.content + .map(block => block.type === 'text' ? block.text : '').join('\n') + expect(preEpochLowerText).toContain('seq 3') + expect(preEpochLowerText).not.toContain('seq 2') + + const preEpochUpper = await execute({ + session_id: persisted, + query: 'pre-epoch fractional needle', + time_to: '1969-12-31T19:59:59.8769999-04:00', + }) + expect(preEpochUpper.isError).toBe(false) + const preEpochUpperText = preEpochUpper.content + .map(block => block.type === 'text' ? block.text : '').join('\n') + expect(preEpochUpperText).toContain('seq 2') + expect(preEpochUpperText).not.toContain('seq 3') + }) }) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index c33b1c9967..2afb4e9dc7 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -382,6 +382,137 @@ describe('input validation and translation', () => { }) }) + it.each([ + ['one fractional digit', '2026-07-24T00:00:00.1Z', 100], + ['two fractional digits', '2026-07-24T00:00:00.12Z', 120], + ['three fractional digits', '2026-07-24T00:00:00.123Z', 123], + ])('normalizes %s into an exact integer epoch-millisecond filter', async (_case, value, offset) => { + const mounted = await mount() + await mounted.call('session_search', { + query: 'q', + created_at_from: value, + }) + const expected = Date.parse('2026-07-24T00:00:00.000Z') + offset + expect(Number.isFinite(expected)).toBe(true) + expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({ + kind: 'created-at', + from: expected, + }) + }) + + it('maps exact same-millisecond decimal bounds to adjacent numeric values without collapsing the interval', async () => { + const mounted = await mount() + const base = Date.parse('2026-07-24T00:00:00.000Z') + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.12300001Z', + created_at_to: '2026-07-24T08:00:00.1239999+08:00', + }) + + expect(result.isError).toBe(false) + expect(text(result)).toContain('No prior session matches found.') + const range = FakeQuery.sessionRequests[0]?.sessionFilters + ?.find(filter => filter.kind === 'created-at') + expect(range).toBeDefined() + if (range?.kind !== 'created-at' || range.from === undefined || range.to === undefined) { + throw new Error('expected complete created-at range') + } + expect(Number.isFinite(range.from)).toBe(true) + expect(Number.isFinite(range.to)).toBe(true) + expect(range.from).toBeGreaterThan(base + 123) + expect(range.from).toBeLessThan(base + 124) + expect(range.to).toBeGreaterThan(base + 123) + expect(range.to).toBeLessThan(base + 124) + expect(range.from).toBeLessThan(range.to) + }) + + it('rejects exact bounds reversed only below one millisecond before calling the provider', async () => { + const mounted = await mount() + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.12300002Z', + created_at_to: '2026-07-24T00:00:00.12300001Z', + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('compares unequal-length exact remainders with implicit trailing decimal zeroes', async () => { + const mounted = await mount() + const ordered = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.1231Z', + created_at_to: '2026-07-24T00:00:00.12311Z', + }) + expect(ordered.isError).toBe(false) + + const reversed = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.12311Z', + created_at_to: '2026-07-24T00:00:00.1231Z', + }) + expect(errorCode(reversed)).toBe('SESSION_QUERY_INVALID_FILTER') + }) + + it('treats trailing-zero fractional spellings as the same exact instant', async () => { + const mounted = await mount() + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.1230000100Z', + created_at_to: '2026-07-24T00:00:00.12300001Z', + }) + + expect(result.isError).toBe(false) + expect(FakeQuery.sessionRequests).toHaveLength(1) + }) + + it('maps fractional bounds correctly across zero and for negative pre-epoch milliseconds', async () => { + const mounted = await mount() + await mounted.call('session_search', { + query: 'q', + created_at_from: '1970-01-01T00:00:00.0000001Z', + event_time_to: '1969-12-31T23:59:59.9999999Z', + }) + expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({ + kind: 'created-at', + from: Number.MIN_VALUE, + }) + expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({ + kind: 'time', + to: -Number.MIN_VALUE, + }) + + await mounted.call('session_event_search', { + query: 'q', + time_from: '1969-12-31T23:59:59.87600001Z', + time_to: '1969-12-31T19:59:59.8769999-04:00', + }) + const range = FakeQuery.eventRequests[0]?.filters?.find(filter => filter.kind === 'time') + expect(range).toBeDefined() + if (range?.kind !== 'time' || range.from === undefined || range.to === undefined) { + throw new Error('expected complete event time range') + } + expect(range.from).toBeGreaterThan(-124) + expect(range.from).toBeLessThan(-123) + expect(range.to).toBeGreaterThan(-124) + expect(range.to).toBeLessThan(-123) + expect(range.from).toBeLessThan(range.to) + }) + + it('rejects a normalized timestamp when the platform parser cannot produce a finite value', async () => { + const mounted = await mount() + vi.spyOn(Date, 'parse').mockReturnValueOnce(Number.NaN) + + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.123456Z', + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') + expect(FakeQuery.sessionRequests).toEqual([]) + }) + it('compiles one-sided timestamps and independent root/parent clauses', async () => { const mounted = await mount() await mounted.call('session_search', { @@ -463,6 +594,109 @@ describe('workspace authority and lineage redaction', () => { expect(output).not.toContain('hidden-grandchild-secret') }) + it('sanitizes a real outside-workspace ancestor cycle before the lineage error reaches the model', async () => { + const mounted = await mount() + const hiddenA = SessionId('hidden-cycle-a-secret') + const hiddenB = SessionId('hidden-cycle-b-secret') + createSession(mounted.ctx, hiddenA, '/outside', 2, hiddenB) + createSession(mounted.ctx, hiddenB, '/outside', 3, hiddenA) + const target = createSession(mounted.ctx, 'visible-cycle-target', '/work', 4, hiddenA) + + const result = await mounted.call('session_trace', { session_id: target.id }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_LINEAGE') + expect(text(result)).toBe('Error: session lineage is invalid') + const presentation = JSON.stringify(result) + expect(presentation).not.toContain(hiddenA) + expect(presentation).not.toContain(hiddenB) + }) + + it.each([ + { + name: 'typed query error', + makeError: () => new SessionQueryError( + 'unrelated persistence failure', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ), + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: 'unrelated persistence failure', + }, + { + name: 'plain error', + makeError: () => new Error('unrelated plain trace failure'), + code: undefined, + message: 'unrelated plain trace failure', + }, + ])('preserves an unrelated $name from lineage tracing', async ({ makeError, code, message }) => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'trace-failure-target', '/work') + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockRejectedValueOnce(makeError()) + + const result = await mounted.call('session_trace', { session_id: target.id }) + + expect(errorCode(result)).toBe(code) + expect(text(result)).toBe(`Error: ${message}`) + }) + + it('preserves caller cancellation while a lineage trace is pending', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work') + const trace = await mounted.ctx.sessionQuery.traceSession(target.id) + let started!: () => void + const traceStarted = new Promise((resolve) => { started = resolve }) + let finish!: (value: typeof trace) => void + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => { + started() + return new Promise((resolve) => { finish = resolve }) + }) + const controller = new AbortController() + const cancellation = new SessionQueryError('lineage trace cancelled', 'SESSION_QUERY_ABORTED') + + const pending = mounted.call( + 'session_trace', + { session_id: target.id }, + { signal: controller.signal }, + ) + await traceStarted + controller.abort(cancellation) + finish(trace) + const result = await pending + + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: lineage trace cancelled') + }) + + it('gives caller cancellation precedence when a pending trace rejects with invalid lineage', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'cancelled-invalid-lineage-target', '/work') + let started!: () => void + const traceStarted = new Promise((resolve) => { started = resolve }) + let fail!: (error: SessionQueryError) => void + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => { + started() + return new Promise((_resolve, reject) => { fail = reject }) + }) + const controller = new AbortController() + const cancellation = new SessionQueryError('lineage trace cancelled first', 'SESSION_QUERY_ABORTED') + + const pending = mounted.call( + 'session_trace', + { session_id: target.id }, + { signal: controller.signal }, + ) + await traceStarted + controller.abort(cancellation) + fail(new SessionQueryError( + 'session lineage contains a cycle at "hidden-race-secret"', + 'SESSION_QUERY_INVALID_LINEAGE', + )) + const result = await pending + + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: lineage trace cancelled first') + expect(JSON.stringify(result)).not.toContain('hidden-race-secret') + }) + it('renders branching descendants in source preorder with one indented marker per pruned subtree', async () => { const mounted = await mount() const target = createSession(mounted.ctx, 'branch-target', '/work', 20)