fix(web): align session search contracts

This commit is contained in:
Hypatia May
2026-07-27 18:38:00 +08:00
parent 621f414407
commit 4727d742db
42 changed files with 235 additions and 196 deletions

View File

@@ -628,6 +628,8 @@ export class SessionQuerySqlite extends SessionQueryService {
offset,
]
assertPortableBindingCount(bindings.length)
// The browser fixture mirrors these rank keys in
// `packages/client/connection/src/client/fixture.ts`; update both together.
return this._requireDb().prepare(`
${selected.sql},
filtered AS (

View File

@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851

View File

@@ -289,6 +289,34 @@ describe('SQLite session search', () => {
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] })
})
it('excludes assistant reasoning while indexing visible answer text', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('reasoning'))
session.append(
'assistant/message',
{
turn: 1,
step: 1,
content: [
{ type: 'reasoning', text: 'private-chain-marker' },
{ type: 'text', text: 'visible-answer-marker' },
],
provenance: { provider: 'mock', model: 'mock' },
},
{ surfaceOp: 'append' },
)
await expect(ctx.sessionQuery.searchSessions({ query: 'private-chain-marker' }))
.resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'visible-answer-marker' }))
.resolves.toMatchObject({
items: [{
header: { id: session.id },
bestMatch: { snippet: 'visible-answer-marker' },
}],
})
})
it('searches all surfaces by default and applies metadata before ranking', async () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 })
const parent = SessionId('parent')
@@ -1190,7 +1218,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const staleOwner = await liveContext({ path: stalePath })
await (staleOwner.sessionQuery as SessionQuerySqlite).close()
const stale = new DatabaseSync(stalePath)
stale.exec('PRAGMA user_version = 999')
stale.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION - 1}`)
stale.close()
const staleCtx = await liveContext({ path: stalePath })
staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') })

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: ebc577975f874a1a60c84061f9148282742bfdf2
README.zh.md: c4d0c27c846bad6b9b621b6db391be1d4ee69fed
# pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md
README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063
README.zh.md: cc79a6f48b4c997a4e940f99aaab169291e2b900

View File

@@ -23,7 +23,7 @@ Persistence is optional and may mount or unmount dynamically. Cross-corpus listi
`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`.
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; reasoning blocks, structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
## Full-text methods

View File

@@ -23,7 +23,7 @@
`SessionResultFilter` 覆盖 id、可空 cwd、创建时间范围、可空父级和来源可用性。`SessionEventResultFilter` 覆盖 seq/时间范围、事件类型、接口和语义文本。过滤器数组使用 AND同一列表子句内的值使用 OR。空列表值不匹配任何内容范围包含端点而格式错误的范围或封闭联合值以 `SESSION_QUERY_INVALID_FILTER` 失败。
文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每个空白运行匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()``buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;结构边界、流分片、请求 header 和未知声明合并变体不产生文档。
文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每个空白运行匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()``buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;推理reasoning块、结构边界、流分片、请求 header 和未知声明合并变体不产生文档。
## 全文方法

View File

@@ -75,8 +75,9 @@ function contentText(content: readonly SessionContentBlock[]): string {
function blockText(block: SessionContentBlock): string[] {
switch (block.type) {
case 'text':
case 'reasoning':
return [block.text]
case 'reasoning':
return []
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':

View File

@@ -54,8 +54,20 @@ describe('session-query semantic extraction', () => {
]
for (const event of events.slice(0, 4)) {
expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested')
expect(extractSessionEventText(event)).toBe('visible\nread\n{"path":"a"}\nnested')
}
expect(extractSessionEventText({
type: 'assistant/message',
seq: 9,
time: 10,
data: {
turn: 1,
step: 1,
content: [{ type: 'reasoning', text: 'private thought' }],
provenance: { provider: 'mock', model: 'mock' },
},
surfaceOp: 'append',
})).toBe('')
expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy')
expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}')
expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS')