Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-31 11:39:58 +08:00
190 changed files with 4231 additions and 770 deletions

View File

@@ -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/client/connection/README.md
README.md: d2fda9f15125915594259e01e5b153609ceb21bb
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45

View File

@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience

View File

@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验

View File

@@ -1,12 +1,12 @@
// Central contract re-export point: every contract import inside
// web-runtime goes through this single file.
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
// The ./api and ./client subpath exports are the browser-safe channels added for this.
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
// transportError moved down to the apiproxy api layer (it belongs beside
// RpcResult, its subject); re-exported here so connection consumers keep one
// contract entry point.
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
export {
RpcId,
SESSION_SEARCH_RESULT_LIMIT,
transportError,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'

View File

@@ -26,13 +26,14 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest<P>(payload: P): RpcRequest<P> {
@@ -575,6 +576,144 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
case 'text':
return [block.text]
case 'reasoning':
return []
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':
return block.content.flatMap(searchBlockText)
default:
return []
}
}
/** One current-surface user/assistant/steering document, if searchable. */
function searchEventText(event: SessionEvent): string {
const content = event.type === 'user/message'
? event.data.content
: event.type === 'assistant/message' || event.type === 'steering/message'
? event.data.message.content
: undefined
if (content === undefined) return ''
return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
}
interface FixtureSearchToken {
value: string
/** Inclusive code-point offset in the whitespace-normalized display text. */
start: number
/** Exclusive code-point offset in the whitespace-normalized display text. */
end: number
}
/**
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
* Keeping phrase matching token-based prevents the development fixture from
* promising arbitrary within-token substring behavior that production lacks.
*/
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
const text = value.replace(/\s+/gu, ' ').trim()
const characters = Array.from(text)
const tokens: FixtureSearchToken[] = []
let start: number | undefined
let raw = ''
const flush = (end: number): void => {
if (start !== undefined) {
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
if (folded !== '') tokens.push({ value: folded, start, end })
}
start = undefined
raw = ''
}
for (let index = 0; index < characters.length; index++) {
const character = characters[index] as string
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
if (tokenBase === '') {
if (start !== undefined) raw += character
continue
}
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
start ??= index
raw += character
} else {
flush(index)
}
}
flush(characters.length)
return { text, tokens }
}
interface FixturePhraseMatch {
count: number
start: number
end: number
}
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
let count = 0
let firstStart = 0
let firstEnd = 0
for (let start = 0; start <= document.length - phrase.length; start++) {
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
count++
if (count === 1) {
firstStart = document[start]?.start ?? 0
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
}
}
return { count, start: firstStart, end: firstEnd }
}
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
const characters = Array.from(value)
if (characters.length <= 120) return value
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
const boundedEnd = Math.min(
characters.length,
Math.max(boundedStart + 1, matchEnd),
)
const center = Math.floor((boundedStart + boundedEnd) / 2)
let start = Math.min(
characters.length - 118,
Math.max(0, center - Math.floor(118 / 2)),
)
let end = start + 118
if (start === 0) {
end = 119
} else if (end === characters.length) {
start = characters.length - 119
}
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
}
interface FixtureSearchCandidate {
sessionId: SessionId
seq: number
time: number
text: string
matchCount: number
matchStart: number
matchEnd: number
documentLength: number
}
/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
if (a.time !== b.time) return b.time - a.time
if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
return b.seq - a.seq
}
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
@@ -1072,6 +1211,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
search: (request, signal) => {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'fixture session search was aborted',
details: {},
})
}
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
const matches = sessions.flatMap((summary) => {
const log = logs.get(summary.sessionId) ?? []
const current = new Set(foldSurface(log).nodes)
const best = log.flatMap((event): FixtureSearchCandidate[] => {
if (!current.has(event.seq)) return []
const eventText = searchEventText(event)
const document = searchTokenSpans(eventText)
const match = phraseMatch(document.tokens, query)
if (match.count === 0) return []
return [{
sessionId: summary.sessionId,
seq: event.seq,
time: event.time,
text: document.text,
matchCount: match.count,
matchStart: match.start,
matchEnd: match.end,
documentLength: Array.from(eventText).length,
}]
}).sort(compareSearchCandidates)[0]
return best === undefined ? [] : [best]
}).sort(compareSearchCandidates)
return ok(request, {
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
sessionId: match.sessionId,
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
})),
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
})
},
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
@@ -1776,20 +1954,30 @@ export class FixtureApiClient extends AbstractApiClient {
protected override async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
const response = await this.dispatch(
method,
request as RpcRequest<never>,
signal ?? new AbortController().signal,
) as RpcResponse<ResponseValue<K>>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
return response
}
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
private dispatch(
method: keyof RpcMethodMap,
request: RpcRequest<never>,
signal: AbortSignal,
): Promise<RpcResponse<unknown>> {
switch (method) {
case 'session.list': return this.api.sessions.list(request)
case 'session.search': return this.api.sessions.search(request, signal)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
@@ -1810,8 +1998,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)

View File

@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
export {
RpcId,
AbstractApiClient,
transportError,
} from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
@@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),

View File

@@ -52,6 +52,59 @@ describe('createFixtureApi', () => {
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
it('searches current message text with literal unicode61-style token phrases', async () => {
const api = createFixtureApi()
const signal = new AbortController().signal
const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
expect(phrase.result).toMatchObject({
ok: true,
value: {
items: [{ sessionId: 'fx-alpha' }],
hasMore: false,
},
})
if (!phrase.result.ok) throw new Error('search failed')
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
timing().appendUser(
'fx-alpha',
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
)
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
if (!late.result.ok) throw new Error('late search failed')
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
expect(lateSnippet).toContain('late café token')
expect(lateSnippet.startsWith('…')).toBe(true)
expect(lateSnippet.endsWith('…')).toBe(true)
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
expect(substring.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
expect(punctuationOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
expect(reasoningOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const aborted = new AbortController()
aborted.abort()
await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
.resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
})
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
@@ -833,6 +886,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
it('covers the whole unary dispatch table', async () => {
const client = new FixtureApiClient()
expect((await client.sessions.search(
{ query: 'fixture' },
new AbortController().signal,
)).result.ok).toBe(true)
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId