fix: serialize paginated session searches

This commit is contained in:
Hypatia May
2026-07-24 19:47:23 +08:00
parent 795af3174e
commit 66585635c8
16 changed files with 133 additions and 39 deletions

View File

@@ -9,7 +9,7 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on
| `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages |
| `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools |
The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters.
The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters.
`session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id.

View File

@@ -211,7 +211,6 @@ export function apply(ctx: Context, config: Config): void {
parameters: SESSION_SEARCH_PARAMETERS,
output: TEXT_OUTPUT,
timeoutMs: resolved.searchTimeoutMs,
isConcurrencySafe: () => true,
execute: (args, exec) => executeSessionSearch(ctx, args, exec, resolved.maxSearchResults),
presentCall: presentSessionSearchCall,
}))
@@ -222,7 +221,6 @@ export function apply(ctx: Context, config: Config): void {
parameters: EVENT_SEARCH_PARAMETERS,
output: TEXT_OUTPUT,
timeoutMs: resolved.searchTimeoutMs,
isConcurrencySafe: () => true,
execute: (args, exec) => executeEventSearch(ctx, args, exec, resolved.maxSearchResults),
presentCall: presentEventSearchCall,
}))

View File

@@ -248,15 +248,13 @@ describe('registration and schemas', () => {
expect(sessionSchema?.parameters).not.toHaveProperty('properties.cwd')
expect(mounted.ctx.tools.get('session_search')?.timeoutMs).toBe(1234)
expect(mounted.ctx.tools.get('session_trace')?.timeoutMs).toBeUndefined()
const safeArgs: Record<string, unknown> = {
session_search: { query: 'q' },
session_event_search: { query: 'q' },
const parallelArgs: Record<string, unknown> = {
session_trace: {},
session_event_trace: { seq: 0 },
session_event_read: { seq: 0 },
}
for (const name of names) {
expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(safeArgs[name])).toBe(true)
for (const [name, args] of Object.entries(parallelArgs)) {
expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(args)).toBe(true)
}
expect(mounted.ctx.tools.get('session_search')?.output.render({}, 'rendered'))
.toEqual([{ type: 'text', text: 'rendered' }])
@@ -287,6 +285,27 @@ describe('registration and schemas', () => {
.not.toContain('tool:session-query')
})
it('keeps generation-bound searches exclusive while exact observations remain parallel', async () => {
const mounted = await mount()
const classifications = [
['session_search', { query: 'q' }, 'exclusive'],
['session_event_search', { query: 'q' }, 'exclusive'],
['session_trace', {}, 'parallel'],
['session_event_trace', { seq: 0 }, 'parallel'],
['session_event_read', { seq: 0 }, 'parallel'],
] as const
for (const [name, args, kind] of classifications) {
expect(mounted.ctx.tools.executionMode({
name,
arguments: args,
callId: CallId(`mode-${name}`),
signal: new AbortController().signal,
agent: fakeAgent(mounted.caller),
})).toEqual({ kind })
}
})
it('fails invalid direct config before registering anything', async () => {
const mounted = await mount()
for (const maxSearchResults of [0, 1.5, Number.NaN]) {