fix(web): align session search contracts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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 packages/host/apiproxy/README.md
|
||||
README.md: 08eeddaf9ec8cba315d0cc85b41750005bec53c0
|
||||
README.zh.md: 984e1a340298afae48799a2b09cab8583d9c780a
|
||||
README.md: 31a41a945e11a610f2c67c0ccf89a4d2677a1067
|
||||
README.zh.md: b78f7b19b0bde51327f09f282ef84ab087fab25b
|
||||
|
||||
@@ -14,7 +14,7 @@ The mux stream projects the latest log-backed title as a validated `session/titl
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points; a malformed non-string provider snippet fails closed at the Host, and the response schema independently rejects an oversized snippet at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches.
|
||||
|
||||
@@ -39,3 +39,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||
|
||||
@@ -14,7 +14,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点;如果提供方返回格式错误的非字符串 snippet,系统会在宿主侧直接失败,响应 schema 则会在每个客户端边界独立拒绝超长的 snippet。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
|
||||
@@ -39,3 +39,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
|
||||
@@ -24,6 +24,11 @@ import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSearchItem,
|
||||
SessionSummary, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
truncateUnicodeCodePoints,
|
||||
} from './api/session-search.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
@@ -38,15 +43,9 @@ import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Product contract: sidebar search returns one bounded page and no cursor. */
|
||||
const SESSION_SEARCH_LIMIT = 20
|
||||
|
||||
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
|
||||
/** Product contract: snippets contain at most 240 Unicode code points. */
|
||||
const SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT = 240
|
||||
|
||||
/** Bound cold-log stat fan-out so an aborted search stops launching new work. */
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
|
||||
@@ -58,28 +57,6 @@ function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
/** Copy at most the product-visible code-point prefix without splitting a surrogate pair. */
|
||||
function boundedSessionSearchSnippet(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('session search provider returned a non-string snippet')
|
||||
}
|
||||
let end = 0
|
||||
for (
|
||||
let count = 0;
|
||||
count < SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT && end < value.length;
|
||||
count++
|
||||
) {
|
||||
const first = value.charCodeAt(end)
|
||||
const hasSurrogatePair = first >= 0xD800
|
||||
&& first <= 0xDBFF
|
||||
&& end + 1 < value.length
|
||||
&& value.charCodeAt(end + 1) >= 0xDC00
|
||||
&& value.charCodeAt(end + 1) <= 0xDFFF
|
||||
end += hasSurrogatePair ? 2 : 1
|
||||
}
|
||||
return end === value.length ? value : value.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
@@ -683,8 +660,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const seenCursors = new Set<SessionSearchCursor>()
|
||||
let cursor: SessionSearchCursor | undefined
|
||||
let providerCallCount = 0
|
||||
let providerPageLimit = SESSION_SEARCH_LIMIT
|
||||
while (authorized.length <= SESSION_SEARCH_LIMIT) {
|
||||
let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
|
||||
throw new Error(
|
||||
@@ -739,14 +716,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Host visibility is the authorization boundary. Consume the
|
||||
// provider's globally ranked stream rather than binding every
|
||||
// visible id into one SQLite statement, then re-check complete
|
||||
// provenance before emitting any snippet. Inspect exactly the
|
||||
// declared array entries so a custom iterator cannot overproduce.
|
||||
for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) {
|
||||
const hit = page.items[itemIndex]
|
||||
if (hit === undefined) {
|
||||
throw new Error(`session search provider omitted item at index ${itemIndex}`)
|
||||
}
|
||||
if (authorized.length > SESSION_SEARCH_LIMIT) continue
|
||||
// provenance before emitting any snippet.
|
||||
for (const hit of page.items) {
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
|
||||
if (
|
||||
!visibleIds.has(hit.header.id)
|
||||
|| hit.bestMatch.sessionId !== hit.header.id
|
||||
@@ -754,7 +726,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|
||||
|| acceptedIds.has(hit.header.id)
|
||||
) continue
|
||||
const snippet = boundedSessionSearchSnippet(hit.bestMatch.snippet)
|
||||
const snippet = truncateUnicodeCodePoints(
|
||||
hit.bestMatch.snippet,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
)
|
||||
acceptedIds.add(hit.header.id)
|
||||
authorized.push({
|
||||
sessionId: hit.header.id,
|
||||
@@ -768,18 +743,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
seenCursors.add(nextCursor)
|
||||
}
|
||||
if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
return ok(request, {
|
||||
items: authorized.slice(0, SESSION_SEARCH_LIMIT),
|
||||
hasMore: authorized.length > SESSION_SEARCH_LIMIT,
|
||||
items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isAborted(signal)
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
|
||||
) return cancelled()
|
||||
// XXX: Redact provider details before exposing this gateway beyond
|
||||
// its current single-user local deployment.
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `session search failed: ${String(error)}`,
|
||||
|
||||
@@ -51,5 +51,11 @@ export type {
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
} from './session-search.ts'
|
||||
|
||||
// ---- Method registry and derived generics ----
|
||||
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'
|
||||
|
||||
22
packages/host/apiproxy/src/api/session-search.ts
Normal file
22
packages/host/apiproxy/src/api/session-search.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Maximum number of sessions returned by one sidebar search. */
|
||||
export const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/** Maximum snippet length in Unicode code points. */
|
||||
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
|
||||
|
||||
/**
|
||||
* Return the longest prefix containing at most `maximum` Unicode code points.
|
||||
* @param value - text to bound.
|
||||
* @param maximum - non-negative code-point limit.
|
||||
* @returns `value` unchanged when it fits, otherwise a code-point-safe prefix.
|
||||
*/
|
||||
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
|
||||
let count = 0
|
||||
let end = 0
|
||||
for (const codePoint of value) {
|
||||
if (count === maximum) return value.slice(0, end)
|
||||
count++
|
||||
end += codePoint.length
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSearchItem, SessionSummary } from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
truncateUnicodeCodePoints,
|
||||
} from './session-search.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
@@ -56,28 +61,6 @@ export const sessionListValueSchema = z.object({
|
||||
|
||||
/** Fixed wire bound for one interactive sidebar query. */
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
/** Product response bound validated independently by every client carrier. */
|
||||
const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
/** Maximum response snippet length in Unicode code points. */
|
||||
const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
|
||||
|
||||
/** Early-exit Unicode code-point bound without materializing an iterator result. */
|
||||
function hasAtMostCodePoints(value: string, maximum: number): boolean {
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while (offset < value.length) {
|
||||
if (count === maximum) return false
|
||||
const first = value.charCodeAt(offset)
|
||||
const paired = first >= 0xD800
|
||||
&& first <= 0xDBFF
|
||||
&& offset + 1 < value.length
|
||||
&& value.charCodeAt(offset + 1) >= 0xDC00
|
||||
&& value.charCodeAt(offset + 1) <= 0xDFFF
|
||||
offset += paired ? 2 : 1
|
||||
count++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** session.search request payload. */
|
||||
export const sessionSearchRequestSchema = z.object({
|
||||
@@ -89,7 +72,10 @@ export const sessionSearchRequestSchema = z.object({
|
||||
export const sessionSearchItemSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
snippet: z.string().refine(
|
||||
snippet => hasAtMostCodePoints(snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS),
|
||||
snippet => truncateUnicodeCodePoints(
|
||||
snippet,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
) === snippet,
|
||||
{ message: `search snippet must contain at most ${SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS} Unicode code points` },
|
||||
),
|
||||
}) satisfies z.ZodType<Wire<SessionSearchItem>>
|
||||
|
||||
@@ -536,12 +536,10 @@ describe('session.search', () => {
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects an oversized provider page before iterating its items', async () => {
|
||||
it('rejects an oversized provider page', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = new Array<SessionSearchHit>(21)
|
||||
const iterate = vi.fn(() => oversized.values())
|
||||
Object.defineProperty(oversized, Symbol.iterator, { value: iterate })
|
||||
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
@@ -554,15 +552,12 @@ describe('session.search', () => {
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 21 items; maximum is 20')
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the learned provider limit for the overproduction guard', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = new Array<SessionSearchHit>(11)
|
||||
const iterate = vi.fn(() => oversized.values())
|
||||
Object.defineProperty(oversized, Symbol.iterator, { value: iterate })
|
||||
const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (providerRequest.limit === 20) {
|
||||
return Promise.reject(new SessionQueryError(
|
||||
@@ -584,7 +579,6 @@ describe('session.search', () => {
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 11 items; maximum is 10')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
|
||||
@@ -617,58 +611,6 @@ describe('session.search', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the provider returns a non-string snippet', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({
|
||||
items: [{
|
||||
...visible,
|
||||
bestMatch: { ...visible.bestMatch, snippet: 42 },
|
||||
}],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('malformed-snippet'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toContain('non-string snippet')
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
})
|
||||
|
||||
it('inspects only numerically stored items when a compliant page overrides iteration', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of visible) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const stored = visible.slice(0, 1)
|
||||
const iterate = vi.fn(() => visible.values())
|
||||
Object.defineProperty(stored, Symbol.iterator, { value: iterate })
|
||||
const searchSessions = vi.fn(() => Promise.resolve({ items: stored }))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('custom-iterator'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible-0', snippet: 'match 0' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the provider repeats a continuation cursor', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
|
||||
Reference in New Issue
Block a user