Merge finalized Web transcript parent
# Conflicts: # apps/cli/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/client/ui-trajectory/README.i18n.yaml # packages/client/ui-trajectory/README.zh.md
This commit is contained in:
880
packages/host/apiproxy/tests/api-proxy-search.spec.ts
Normal file
880
packages/host/apiproxy/tests/api-proxy-search.spec.ts
Normal file
@@ -0,0 +1,880 @@
|
||||
/**
|
||||
* Host session.search projection: list-equivalent visibility, fixed message
|
||||
* filters and result bound, cancellation mapping, and unavailable/failure
|
||||
* behavior.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
SessionQueryError,
|
||||
type SessionSearchHit,
|
||||
type SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, stat: vi.fn(actual.stat) }
|
||||
})
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
function request(query: string): RpcRequest<{ query: string }> {
|
||||
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
||||
}
|
||||
|
||||
function header(id: string, cwd: string | null = '/project'): SessionHeader {
|
||||
return {
|
||||
version: 0,
|
||||
id: sid(id),
|
||||
createdAt: 100,
|
||||
...(cwd === null ? {} : { cwd }),
|
||||
}
|
||||
}
|
||||
|
||||
function hit(id: string, index = 0): SessionSearchHit {
|
||||
const session = header(id)
|
||||
return {
|
||||
header: session,
|
||||
live: true,
|
||||
persisted: false,
|
||||
bestMatch: {
|
||||
sessionId: session.id,
|
||||
seq: index,
|
||||
type: 'user/message',
|
||||
time: 200 + index,
|
||||
surface: 'current',
|
||||
snippet: `match ${index}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function baseContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('session.search', () => {
|
||||
it('searches only list-visible ids and current conversation-message events', async () => {
|
||||
const ctx = await baseContext()
|
||||
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
|
||||
live.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'live text' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const cold = header('cold', '/cold')
|
||||
const legacy = header('legacy', null)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([cold, legacy]),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
|
||||
const searchSessions = vi.fn((
|
||||
_request: SessionSearchRequest,
|
||||
_exec?: { signal?: AbortSignal },
|
||||
) => Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
header: legacy,
|
||||
live: false,
|
||||
persisted: true,
|
||||
bestMatch: {
|
||||
sessionId: legacy.id,
|
||||
seq: 3,
|
||||
type: 'user/message' as const,
|
||||
time: 190,
|
||||
surface: 'current' as const,
|
||||
snippet: 'must remain hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
header: cold,
|
||||
live: false,
|
||||
persisted: true,
|
||||
bestMatch: {
|
||||
sessionId: cold.id,
|
||||
seq: 4,
|
||||
type: 'assistant/message' as const,
|
||||
time: 200,
|
||||
surface: 'current' as const,
|
||||
snippet: 'the matching answer',
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const response = await api.sessions.search(request('matching answer'), signal)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
|
||||
SessionSearchRequest,
|
||||
{ signal: AbortSignal },
|
||||
]
|
||||
expect(query).toEqual({
|
||||
query: 'matching answer',
|
||||
eventFilters: [
|
||||
{
|
||||
kind: 'type',
|
||||
values: ['user/message', 'assistant/message', 'steering/message'],
|
||||
},
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: 20,
|
||||
})
|
||||
expect(exec.signal).toBe(signal)
|
||||
})
|
||||
|
||||
it('returns an empty page without invoking the index when no session is visible', async () => {
|
||||
const ctx = await baseContext()
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
|
||||
const response = await api.sessions.search(
|
||||
request('anything'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [], hasMore: false },
|
||||
})
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects snippets whose provider provenance violates the Host filters', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
const withBestMatch = (
|
||||
index: number,
|
||||
bestMatch: Partial<SessionSearchHit['bestMatch']>,
|
||||
): SessionSearchHit => {
|
||||
const base = hit('visible', index)
|
||||
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
|
||||
}
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({
|
||||
items: [
|
||||
withBestMatch(0, { sessionId: sid('hidden') }),
|
||||
withBestMatch(1, { surface: 'shadowed' }),
|
||||
withBestMatch(2, { type: 'tool/result' }),
|
||||
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
|
||||
],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('match'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('pages the globally ranked stream until the 20-item Host boundary is known', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [hit('hidden-ranked-first'), ...items.slice(0, 19)],
|
||||
nextCursor: 'page-2',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: items.slice(19) })
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions,
|
||||
} as never)
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('match'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items).toHaveLength(20)
|
||||
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
|
||||
})
|
||||
|
||||
it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const invalidLimit = new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
const limit = providerRequest.limit
|
||||
if (limit === undefined) throw new Error('Host search must request an explicit provider limit')
|
||||
if (limit > 10) return Promise.reject(invalidLimit)
|
||||
const offset = providerRequest.cursor === undefined
|
||||
? 0
|
||||
: Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10)
|
||||
const end = Math.min(items.length, offset + limit)
|
||||
return Promise.resolve({
|
||||
items: items.slice(offset, end),
|
||||
...end < items.length ? { nextCursor: `offset-${end}` } : {},
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('adaptive-page-limit'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items.map(item => item.sessionId))
|
||||
.toEqual(items.slice(0, 20).map(item => item.header.id))
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => ({
|
||||
limit: providerRequest.limit,
|
||||
cursor: providerRequest.cursor,
|
||||
}))).toEqual([
|
||||
{ limit: 20, cursor: undefined },
|
||||
{ limit: 10, cursor: undefined },
|
||||
{ limit: 10, cursor: 'offset-10' },
|
||||
{ limit: 10, cursor: 'offset-20' },
|
||||
])
|
||||
})
|
||||
|
||||
it('counts a page-limit probe inside the 100-call budget', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const invalidLimit = new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (searchSessions.mock.calls.length === 1) {
|
||||
expect(providerRequest).toMatchObject({ limit: 20 })
|
||||
return Promise.reject(invalidLimit)
|
||||
}
|
||||
expect(providerRequest.limit).toBe(10)
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
nextCursor: `page-${searchSessions.mock.calls.length}`,
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('endless-pages'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('100-call work budget')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => {
|
||||
const ctx = await baseContext()
|
||||
const oldOnly = hit('old-only', 0)
|
||||
const shared = hit('shared', 1)
|
||||
const freshFirst = hit('fresh-first', 2)
|
||||
const freshLast = hit('fresh-last', 3)
|
||||
for (const item of [oldOnly, shared, freshFirst, freshLast]) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const late = hit('late-visible', 4)
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const invalidLimit = new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
switch (searchSessions.mock.calls.length) {
|
||||
case 1:
|
||||
expect(providerRequest).toMatchObject({ limit: 20 })
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.reject(invalidLimit)
|
||||
case 2:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.resolve({
|
||||
items: [oldOnly, shared],
|
||||
nextCursor: 'old-cursor',
|
||||
})
|
||||
case 3:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest.cursor).toBe('old-cursor')
|
||||
ctx.sessions.create(late.header.id, { meta: late.header })
|
||||
return Promise.reject(stale)
|
||||
case 4:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.resolve({
|
||||
items: [freshFirst, shared],
|
||||
nextCursor: 'old-cursor',
|
||||
})
|
||||
case 5:
|
||||
expect(providerRequest).toMatchObject({ limit: 10 })
|
||||
expect(providerRequest.cursor).toBe('old-cursor')
|
||||
return Promise.resolve({ items: [freshLast, late] })
|
||||
default:
|
||||
return Promise.reject(new Error('unexpected provider call'))
|
||||
}
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('stale-restart'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [
|
||||
{ sessionId: 'fresh-first', snippet: 'match 2' },
|
||||
{ sessionId: 'shared', snippet: 'match 1' },
|
||||
{ sessionId: 'fresh-last', snippet: 'match 3' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('counts continuous stale restarts against the 100-call budget', async () => {
|
||||
const ctx = await baseContext()
|
||||
const partial = hit('partial')
|
||||
ctx.sessions.create(partial.header.id, { meta: partial.header })
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (searchSessions.mock.calls.length > 100) {
|
||||
return Promise.reject(new Error('provider was called after the shared budget'))
|
||||
}
|
||||
if (providerRequest.cursor !== undefined) return Promise.reject(stale)
|
||||
return Promise.resolve({
|
||||
items: [partial],
|
||||
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('stale-churn'),
|
||||
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('100-call work budget')
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('gives abort priority over a coincident stale continuation failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
|
||||
.mockImplementationOnce(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(stale)
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('abort-stale'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not retry a stale first-page failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
|
||||
'provider generation changed before paging',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('first-page-stale'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not adapt an invalid-limit continuation failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
|
||||
.mockRejectedValueOnce(new SessionQueryError(
|
||||
'continuation limit is invalid',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('continuation-invalid-limit'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => (
|
||||
providerRequest as SessionSearchRequest
|
||||
).limit))
|
||||
.toEqual([20, 20])
|
||||
})
|
||||
|
||||
it('stops page-limit adaptation at one item', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject(
|
||||
new SessionQueryError(
|
||||
`provider rejects ${providerRequest.limit}`,
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
),
|
||||
))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('minimum-page-limit'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
|
||||
.toEqual([20, 10, 5, 2, 1])
|
||||
})
|
||||
|
||||
it('gives abort priority over a coincident invalid first-page limit', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const searchSessions = vi.fn(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(new SessionQueryError(
|
||||
'provider rejects 20',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
))
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('abort-invalid-limit'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects an oversized provider page', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('oversized-page'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
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')
|
||||
})
|
||||
|
||||
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 = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (providerRequest.limit === 20) {
|
||||
return Promise.reject(new SessionQueryError(
|
||||
'provider accepts at most 10 items',
|
||||
'SESSION_QUERY_INVALID_LIMIT',
|
||||
))
|
||||
}
|
||||
return Promise.resolve({ items: oversized })
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('adapted-oversized-page'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 11 items; maximum is 10')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
const expected = `${'x'.repeat(239)}😀`
|
||||
const overlong = {
|
||||
...visible,
|
||||
bestMatch: {
|
||||
...visible.bestMatch,
|
||||
snippet: `${expected}${'y'.repeat(10_000)}`,
|
||||
},
|
||||
}
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({ items: [overlong] }),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('bounded-snippet'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible', snippet: expected }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the provider repeats a continuation cursor', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('repeated-cursor'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('repeated a continuation cursor')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('validates a repeated cursor before accepting the authorized lookahead', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
|
||||
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('repeated-lookahead-cursor'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.message).toContain('repeated a continuation cursor')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
|
||||
const ctx = await baseContext()
|
||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of items) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
|
||||
.mockResolvedValueOnce({ items: items.slice(20) })
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('duplicate-pages'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: true },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.items.map(item => item.sessionId)).toEqual(
|
||||
items.slice(0, 20).map(item => item.header.id),
|
||||
)
|
||||
expect(searchSessions).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('cancels on a continuation page and passes the carrier signal to both calls', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
|
||||
.mockImplementationOnce(() => {
|
||||
controller.abort()
|
||||
return Promise.resolve({ items: [] })
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-continuation'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
for (const call of searchSessions.mock.calls) {
|
||||
expect(call[1]).toEqual({ signal: controller.signal })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => {
|
||||
const ctx = await baseContext()
|
||||
const cold = Array.from(
|
||||
{ length: 32_751 },
|
||||
(_, index) => header(`cold-${index}`, `/cold-${index}`),
|
||||
)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(cold),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
|
||||
items: [hit('cold-32750')],
|
||||
}))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('large corpus'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'cold-32750', snippet: 'match 0' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
|
||||
})
|
||||
|
||||
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
|
||||
const ctx = await baseContext()
|
||||
const controller = new AbortController()
|
||||
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
|
||||
const list = vi.fn((signal?: AbortSignal) => {
|
||||
expect(signal).toBe(controller.signal)
|
||||
return Promise.resolve(cold)
|
||||
})
|
||||
let locateCalls = 0
|
||||
ctx.provide('sessionPersistence', {
|
||||
list,
|
||||
locate: () => {
|
||||
locateCalls++
|
||||
controller.abort()
|
||||
return undefined
|
||||
},
|
||||
} as never)
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-during-visibility'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
expect(locateCalls).toBe(1)
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits every started cold-summary stat before returning cancellation', async () => {
|
||||
const ctx = await baseContext()
|
||||
const controller = new AbortController()
|
||||
const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
|
||||
const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
|
||||
const statMock = vi.mocked(stat)
|
||||
statMock.mockClear()
|
||||
for (const gate of statGates) {
|
||||
statMock.mockImplementationOnce((() => gate.promise) as never)
|
||||
}
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(cold),
|
||||
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
|
||||
} as never)
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
let settled = false
|
||||
const responsePromise = createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-during-cold-stats'),
|
||||
controller.signal,
|
||||
).finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(statMock).toHaveBeenCalledTimes(16)
|
||||
})
|
||||
|
||||
controller.abort()
|
||||
statGates[0]!.resolve({ mtimeMs: 101 })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
|
||||
for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
|
||||
const response = await responsePromise
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps missing composition, query cancellation, and provider failure', async () => {
|
||||
const missingCtx = await baseContext()
|
||||
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const missingApi = createApiProxy(missingCtx, defaults)
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
const cancelledBeforeLookup = await missingApi.sessions.search(
|
||||
request('cancel-before-lookup'),
|
||||
preAborted.signal,
|
||||
)
|
||||
expect(cancelledBeforeLookup.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
|
||||
const missing = await missingApi.sessions.search(
|
||||
request('needle'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(missing.result.ok).toBe(false)
|
||||
if (missing.result.ok) throw new Error('unreachable')
|
||||
expect(missing.result.error.code).toBe('internal')
|
||||
expect(missing.result.error.message).toContain('does not mount')
|
||||
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
|
||||
const searchSessions = vi.fn()
|
||||
.mockRejectedValueOnce(aborted)
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
const api = createApiProxy(ctx, defaults)
|
||||
|
||||
const cancelled = await api.sessions.search(
|
||||
request('first'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(cancelled.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
|
||||
const failed = await api.sessions.search(
|
||||
request('second'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(failed.result.ok).toBe(false)
|
||||
if (failed.result.ok) throw new Error('unreachable')
|
||||
expect(failed.result.error.code).toBe('internal')
|
||||
expect(failed.result.error.message).toContain('database unavailable')
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,7 @@ function scriptedApi(overrides: {
|
||||
return {
|
||||
sessions: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
search: r => ok(r, { items: [], hasMore: false }),
|
||||
create: r => ok(r, { sessionId: sid('s-new') }),
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
@@ -141,6 +142,44 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('round-trips a trimmed session search query and its bounded result metadata', async () => {
|
||||
let seen: RpcRequest<{ query: string }> | undefined
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
search: (request) => {
|
||||
seen = request
|
||||
return ok(request, {
|
||||
items: [{ sessionId: sid('s1'), snippet: 'matching message text' }],
|
||||
hasMore: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await client(api).sessions.search({ query: ' message text ' })
|
||||
expect(seen?.payload).toEqual({ query: 'message text' })
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 's1', snippet: 'matching message text' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an overlong session-search snippet at the client value boundary', async () => {
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
search: request => ok(request, {
|
||||
items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }],
|
||||
hasMore: false,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(client(api).sessions.search({ query: 'message' }))
|
||||
.rejects.toThrow(/240 Unicode code points/)
|
||||
})
|
||||
|
||||
it('routes session fork with its optional cut anchor through the wire', async () => {
|
||||
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
|
||||
const api = scriptedApi({
|
||||
|
||||
@@ -22,6 +22,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
|
||||
},
|
||||
async search(request, signal) {
|
||||
if (request.payload.query === 'hang') {
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false },
|
||||
},
|
||||
}
|
||||
},
|
||||
async create(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
|
||||
},
|
||||
@@ -248,6 +268,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
|
||||
it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => {
|
||||
const c = client()
|
||||
expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({
|
||||
ok: true,
|
||||
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
|
||||
})
|
||||
expect((await c.sessions.create({})).result.ok).toBe(true)
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
const selected = await c.sessions.selectModel({
|
||||
@@ -325,6 +349,68 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
|
||||
})
|
||||
|
||||
it('lets command.execute finish after the 30-second default unary deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
||||
}, milliseconds)
|
||||
return controller.signal
|
||||
})
|
||||
try {
|
||||
const api = fakeApi()
|
||||
api.commands.execute = async (request) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 30_001))
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } },
|
||||
}
|
||||
}
|
||||
const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' })
|
||||
const assertion = expect(execution).resolves.toMatchObject({
|
||||
result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } },
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
vi.advanceTimersByTimeAsync(30_001),
|
||||
assertion,
|
||||
])
|
||||
expect(timeoutSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
timeoutSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps caller and connection aborts on command.execute', async () => {
|
||||
const api = fakeApi()
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
api.commands.execute = async (request, signal) => {
|
||||
started.resolve(signal)
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const execution = client(api).commands.execute(
|
||||
{ sessionId: 's' as never, line: '/hang' },
|
||||
controller.signal,
|
||||
)
|
||||
const handlerSignal = await started.promise
|
||||
|
||||
controller.abort(new Error('connection closed'))
|
||||
|
||||
await expect(execution).rejects.toThrow('connection closed')
|
||||
expect(handlerSignal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into command.execute', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
@@ -339,6 +425,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into session.search', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-search-sig',
|
||||
method: 'session.search',
|
||||
payload: { query: 'hang' },
|
||||
})
|
||||
const pending = handler.fetch(new Request(
|
||||
'http://x/api/session.search',
|
||||
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
|
||||
))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as {
|
||||
rpcId: string
|
||||
result: { error?: { code: string } }
|
||||
}
|
||||
expect(parsed.rpcId).toBe('r-search-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into host.pickDirectory', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
||||
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
sessionSearchRequestSchema, sessionSearchValueSchema, sessionSelectModelRequestSchema,
|
||||
sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import {
|
||||
@@ -150,6 +151,36 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionListRequestSchema.parse({})).toEqual({})
|
||||
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
|
||||
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
|
||||
expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' })
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow()
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/)
|
||||
expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow()
|
||||
expect(sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: 'matching text' }],
|
||||
hasMore: true,
|
||||
})).toEqual({
|
||||
items: [{ sessionId: 's1', snippet: 'matching text' }],
|
||||
hasMore: true,
|
||||
})
|
||||
expect(sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: '😀'.repeat(240) }],
|
||||
hasMore: false,
|
||||
}).items[0]?.snippet).toBe('😀'.repeat(240))
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: 's1', snippet: '😀'.repeat(241) }],
|
||||
hasMore: false,
|
||||
})).toThrow(/240 Unicode code points/)
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: [{ sessionId: '', snippet: 'matching text' }],
|
||||
hasMore: false,
|
||||
})).toThrow()
|
||||
expect(() => sessionSearchValueSchema.parse({
|
||||
items: Array.from(
|
||||
{ length: 21 },
|
||||
(_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }),
|
||||
),
|
||||
hasMore: true,
|
||||
})).toThrow()
|
||||
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
|
||||
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
|
||||
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
|
||||
|
||||
Reference in New Issue
Block a user