Merge pinned master into status bar token metrics

This commit is contained in:
Tianyi Cui
2026-07-31 15:41:52 +08:00
376 changed files with 12429 additions and 1438 deletions

View File

@@ -138,6 +138,44 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* The structured `web_search` result view for fixture turn 66, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
* a snippet but no date, and a source with a title and a date but no snippet.
* `truncated` marks the capped indicator. The shape is the contract's own
* search view minus its wire discriminants.
*/
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
sources: [
{
url: 'https://github.com/deepseek-ai/deepseek-harness',
title: 'DeepSeek Harness — plugin-based agent harness',
snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
publishedAt: '2026-07-01',
},
{
url: 'https://www.deepseek.com/blog/harness-architecture',
snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
},
{
url: 'https://docs.deepseek.com/harness/plugins',
title: 'Writing a harness plugin',
publishedAt: '2026-06-15',
},
],
truncated: true,
}
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
truncated: false,
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -283,6 +321,13 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
// the presenter reads to emit the two-hunk sample: the card draws one path
// header, the first hunk, a `⋯` gap, then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
@@ -347,8 +392,20 @@ function buildAlphaLog(): SessionEvent[] {
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
// Both keep a generic pending call view and add the `web` card only at
// result time, which is the contract's result-only web shape. Named after
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -384,9 +441,33 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'edit':
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
if (str(args.file_path) === 'src/config.ts') {
return {
card: 'diff', title: `Edit ${str(args.file_path)}`,
diffs: [
{ path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
{ path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
],
}
}
return {
card: 'diff', title: `Edit ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
}
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
return {
card: 'diff', title: `Write ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
}
// The web tools keep a GENERIC pending card and add the `web` result card
// only at result time (the contract's result-only web shape); their pending
// kind matches the result kind so a call and its result read as one category.
case 'web_search':
return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
case 'web_fetch':
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
@@ -395,6 +476,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// The web tools keep a generic pending card, so their result card is chosen
// by tool name rather than by the pending card tag: the structured `web` card
// the frontend consumes. The view carries no `content` copy (per the contract
// and the web-result-card note); a capability-less UI falls back to the raw
// `tool/result` content, which this fixture emits from `resultText`.
if (name === 'web_search') {
return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
}
if (name === 'web_fetch') {
return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
}
switch (call.card) {
case 'terminal':
// The sample's own exit status, authored beside it: re-parsing the
@@ -1025,6 +1117,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
// Registry-global archive set mirroring the host: archived sessions keep
// their workspace accounting slot and only grouping surfaces hide them.
const archivedSessionIds: SessionId[] = []
// In-memory browse tree behind the fixture's `browse` picker capability —
// deterministic content mirroring the design mock so assembled Web tests
@@ -1206,6 +1301,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
let failNextHistory = false
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
const streamBreakers = new Set<() => void>()
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
@@ -1229,6 +1326,89 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
beginModelRetry(id: string): void {
const sessionId = sid(id)
const turn = nextTurn.get(sessionId) ?? 0
nextTurn.set(sessionId, turn + 1)
retryScenarios.set(sessionId, { turn, stepStarted: true })
setRunning(sessionId, true)
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
},
/** Record one retry decision, then open the next retry turn. */
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
if (!scenario.stepStarted) {
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `${String(retry)} 次应撤回的回复` } } })
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
scenario.stepStarted = true
}
const failure = { code: 'TRANSPORT', message: '连接被重置' }
append(sessionId, {
type: 'llm/retry',
data: {
turn: scenario.turn, step: 1,
provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
retry, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, {
type: 'turn/end',
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
})
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
nextTurn.set(sessionId, next + 1)
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
scenario.turn = next
scenario.stepStarted = false
},
/** Record one retry decision, then cancel its source turn before the retry starts. */
cancelModelRetryDuringBackoff(id: string, delayMs = 450): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
const failure = { code: 'TRANSPORT', message: '连接被重置' }
append(sessionId, {
type: 'llm/retry',
data: {
turn: scenario.turn, step: 1,
provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
retry: 1, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
retryScenarios.delete(sessionId)
setRunning(sessionId, false)
},
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
completeModelRetry(id: string): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
retryScenarios.delete(sessionId)
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: scenario.turn,
step: 1,
message: assistantMessage(text('重试后的完整回复')),
},
})
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
setRunning(sessionId, false)
},
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
@@ -1613,7 +1793,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
list: request => ok(request, {
items: workspaces.map(w => ({ ...w })),
archivedSessionIds: [...archivedSessionIds],
}),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
@@ -1699,6 +1882,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
return ok(request, { workspace: { ...workspace } })
},
archiveSession: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const { sessionId } = request.payload
if (!archivedSessionIds.includes(sessionId)) {
archivedSessionIds.push(sessionId)
emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] })
}
return ok(request, { archivedSessionIds: [...archivedSessionIds] })
},
},
commands: {
// The catalog mirrors one session's effective view (every fixture
@@ -2079,6 +2272,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
case 'command.list': return this.api.commands.list(request)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)

View File

@@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([
'settings.describe',
'settings.update',
'settings.replace',
'settings.mutate',
'credentials.describe',
'credentials.set',
'credentials.unset',

View File

@@ -122,7 +122,7 @@ export class FakeApiClient implements IApiClient {
}
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))),
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
@@ -134,6 +134,9 @@ export class FakeApiClient implements IApiClient {
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({
archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId],
}))),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real

View File

@@ -19,6 +19,10 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
beginModelRetry(id: string): void
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
completeModelRetry(id: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -835,8 +839,18 @@ describe('createFixtureApi', () => {
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
hooks.beginModelRetry('fx-alpha')
hooks.scheduleModelRetry('fx-alpha')
hooks.completeModelRetry('fx-alpha')
hooks.beginModelRetry('fx-alpha')
hooks.cancelModelRetryDuringBackoff('fx-alpha')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true)
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true)
expect(seen.some(f => f.type === 'session/event'
&& f.event.type === 'turn/end'
&& f.event.data.reason.kind === 'aborted')).toBe(true)
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)

View File

@@ -93,7 +93,7 @@ describe('connection node half', () => {
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
@@ -177,7 +177,7 @@ describe('connection node half over a real HTTP server', () => {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {