|
|
|
@@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
|
|
|
|
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
|
|
|
|
|
import type {
|
|
|
|
|
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
|
|
|
|
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
|
|
|
|
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'
|
|
|
|
@@ -46,6 +46,25 @@ const MARKDOWN_FIXTURE = [
|
|
|
|
|
|
|
|
|
|
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
|
|
|
|
|
|
|
|
|
const DEEPSEEK_REASONING = {
|
|
|
|
|
efforts: [
|
|
|
|
|
{ id: 'off', name: 'Off' },
|
|
|
|
|
{ id: 'high', name: 'High' },
|
|
|
|
|
{ id: 'max', name: 'Max' },
|
|
|
|
|
],
|
|
|
|
|
defaultEffort: 'high',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const OPENAI_REASONING = {
|
|
|
|
|
efforts: [
|
|
|
|
|
{ id: 'off', name: 'Off' },
|
|
|
|
|
{ id: 'medium', name: 'Medium' },
|
|
|
|
|
{ id: 'high', name: 'High' },
|
|
|
|
|
{ id: 'max', name: 'Max' },
|
|
|
|
|
],
|
|
|
|
|
defaultEffort: 'medium',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sid(id: string): SessionId {
|
|
|
|
|
return id as SessionId
|
|
|
|
|
}
|
|
|
|
@@ -379,6 +398,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
|
|
|
|
]
|
|
|
|
|
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
|
|
|
|
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
|
|
|
|
session.sessionId,
|
|
|
|
|
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
|
|
|
|
|
]))
|
|
|
|
|
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
|
|
|
|
let nextSession = 1
|
|
|
|
|
let nextRpc = 1
|
|
|
|
@@ -505,7 +528,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
/** 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; failedStep: number }>()
|
|
|
|
|
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
|
|
|
|
@@ -534,53 +557,62 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
const sessionId = sid(id)
|
|
|
|
|
const turn = nextTurn.get(sessionId) ?? 0
|
|
|
|
|
nextTurn.set(sessionId, turn + 1)
|
|
|
|
|
retryScenarios.set(sessionId, { turn, failedStep: 0 })
|
|
|
|
|
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: 0 } })
|
|
|
|
|
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
|
|
|
|
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 0, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
|
|
|
|
|
append(sessionId, { type: 'step/end', data: { turn, step: 0 } })
|
|
|
|
|
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, synthesizing the later failed step when needed. */
|
|
|
|
|
/** 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}`)
|
|
|
|
|
const failedStep = retry - 1
|
|
|
|
|
if (failedStep > scenario.failedStep) {
|
|
|
|
|
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: failedStep } })
|
|
|
|
|
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: failedStep, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
|
|
|
|
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: failedStep, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
|
|
|
|
|
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: failedStep } })
|
|
|
|
|
scenario.failedStep = failedStep
|
|
|
|
|
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: failedStep, retry, maxRetries: 2, delayMs,
|
|
|
|
|
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
|
|
|
|
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
|
|
|
|
|
},
|
|
|
|
|
/** Finish the timing-hook retry with a finalized response on the next step. */
|
|
|
|
|
/** 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)
|
|
|
|
|
const step = scenario.failedStep + 1
|
|
|
|
|
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step } })
|
|
|
|
|
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
|
|
|
|
append(sessionId, {
|
|
|
|
|
type: 'assistant/message',
|
|
|
|
|
surfaceOp: 'append',
|
|
|
|
|
data: {
|
|
|
|
|
turn: scenario.turn, step, content: text('重试后的完整回复'),
|
|
|
|
|
turn: scenario.turn, step: 1, content: text('重试后的完整回复'),
|
|
|
|
|
provenance: { provider: 'fixture', model: 'fx-1' },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step } })
|
|
|
|
|
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)
|
|
|
|
|
},
|
|
|
|
@@ -678,6 +710,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
|
|
|
|
}
|
|
|
|
|
sessions.push(created)
|
|
|
|
|
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
|
|
|
|
attachedSessions += 1
|
|
|
|
|
const emitSession = (): void => {
|
|
|
|
|
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
|
|
|
@@ -710,6 +743,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
if (doomed) throw new Error('fixture: simulated history transport failure')
|
|
|
|
|
return ok(request, { ...page, ...todos === undefined ? {} : { todos } })
|
|
|
|
|
},
|
|
|
|
|
models: request => ok(request, {
|
|
|
|
|
current: modelTargets.get(request.payload.sessionId)
|
|
|
|
|
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
|
|
|
|
groups: [
|
|
|
|
|
{
|
|
|
|
|
id: 'deepseek',
|
|
|
|
|
name: 'DeepSeek',
|
|
|
|
|
models: [
|
|
|
|
|
{
|
|
|
|
|
id: 'deepseek-v4-flash',
|
|
|
|
|
name: 'DeepSeek-V4-Flash',
|
|
|
|
|
description: '快速响应',
|
|
|
|
|
reasoning: DEEPSEEK_REASONING,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'deepseek-v4-pro',
|
|
|
|
|
name: 'DeepSeek-V4-Pro',
|
|
|
|
|
description: '复杂任务',
|
|
|
|
|
reasoning: DEEPSEEK_REASONING,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'openai',
|
|
|
|
|
name: 'OpenAI',
|
|
|
|
|
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
failures: [],
|
|
|
|
|
}),
|
|
|
|
|
selectModel: (request) => {
|
|
|
|
|
const selected: ModelTarget = {
|
|
|
|
|
provider: request.payload.provider,
|
|
|
|
|
model: request.payload.model,
|
|
|
|
|
...request.payload.reasoningEffort === undefined
|
|
|
|
|
? {}
|
|
|
|
|
: { reasoningEffort: request.payload.reasoningEffort },
|
|
|
|
|
}
|
|
|
|
|
modelTargets.set(request.payload.sessionId, selected)
|
|
|
|
|
return ok(request, { selected })
|
|
|
|
|
},
|
|
|
|
|
prompt: (request) => {
|
|
|
|
|
const { sessionId: id, mode, content } = request.payload
|
|
|
|
|
const summary = summaryOf(id)
|
|
|
|
@@ -744,7 +818,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
turn,
|
|
|
|
|
userText === 'render markdown'
|
|
|
|
|
? MARKDOWN_FIXTURE
|
|
|
|
|
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
|
|
|
|
: userText === 'report model'
|
|
|
|
|
? (() => {
|
|
|
|
|
const target = modelTargets.get(id)
|
|
|
|
|
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
|
|
|
|
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
|
|
|
|
})()
|
|
|
|
|
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
|
|
|
|
)
|
|
|
|
|
return ok(request, { accepted: true as const })
|
|
|
|
|
},
|
|
|
|
@@ -761,6 +841,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|
|
|
|
},
|
|
|
|
|
host: {
|
|
|
|
|
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
|
|
|
|
pickDirectory: request => ok(request, { path: null }),
|
|
|
|
|
},
|
|
|
|
|
workspace: {
|
|
|
|
|
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
|
|
|
|
@@ -1006,9 +1087,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
|
|
|
|
case 'session.list': return this.api.sessions.list(request)
|
|
|
|
|
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)
|
|
|
|
|
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
|
|
|
|
case 'session.prompt': return this.api.sessions.prompt(request)
|
|
|
|
|
case 'session.cancel': return this.api.sessions.cancel(request)
|
|
|
|
|
case 'host.describe': return this.api.host.describe(request)
|
|
|
|
|
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
|
|
|
|
case 'workspace.list': return this.api.workspace.list(request)
|
|
|
|
|
case 'workspace.create': return this.api.workspace.create(request)
|
|
|
|
|
case 'workspace.rename': return this.api.workspace.rename(request)
|
|
|
|
|