Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry
# Conflicts: # apps/web/tests/smoke-real.e2e.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
@@ -9,6 +9,7 @@ export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -124,6 +124,49 @@ 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 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
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
|
||||
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
|
||||
+ 'return { listing, demo }'
|
||||
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:run_code 样本。`), source: { kind: 'user' } } })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
}
|
||||
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
|
||||
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
|
||||
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
|
||||
push({
|
||||
type: 'tool/result', surfaceOp: 'append',
|
||||
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
|
||||
})
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
return events as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
@@ -304,10 +347,11 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ 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 nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
@@ -384,6 +428,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
|
||||
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
|
||||
/** Shared session guard for sessionId-addressed catalog routes: the error
|
||||
* response when the session is unknown, undefined when it exists. */
|
||||
const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<never>> | undefined => {
|
||||
if (summaryOf(request.payload.sessionId) !== undefined) return undefined
|
||||
return err<{ sessionId: SessionId }, never>(request, {
|
||||
code: 'session-not-found',
|
||||
message: `no session ${request.payload.sessionId}`,
|
||||
details: { sessionId: request.payload.sessionId },
|
||||
})
|
||||
}
|
||||
const setRunning = (id: SessionId, running: boolean): void => {
|
||||
const summary = summaryOf(id)
|
||||
if (summary === undefined || summary.running === running) return
|
||||
@@ -596,12 +650,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
}
|
||||
const created: SessionSummary = {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd,
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd })
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
|
||||
}
|
||||
if (workspace !== undefined && options.failWorkspaceAttach) {
|
||||
emitSession()
|
||||
@@ -642,6 +697,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
})
|
||||
}
|
||||
summary.updatedAt = Date.now()
|
||||
// First accepted prompt appends events: the summary stops being blank.
|
||||
summary.blank = false
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: insert a steering message into the current turn; the replay continues.
|
||||
@@ -752,6 +809,52 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
// The catalog mirrors one session's effective view (every fixture
|
||||
// session has an agent, like the real host).
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
commands: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
|
||||
],
|
||||
})
|
||||
},
|
||||
execute: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const line = request.payload.line.trim()
|
||||
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
|
||||
const name = match?.[1]
|
||||
if (name === 'compact' || name === 'echo') {
|
||||
return ok(request, {
|
||||
matched: true as const,
|
||||
result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' },
|
||||
})
|
||||
}
|
||||
if (name === 'goal-fixture') {
|
||||
return ok(request, {
|
||||
matched: true as const,
|
||||
result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` },
|
||||
})
|
||||
}
|
||||
return ok(request, { matched: false as const })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
const conn = new FxInbox<MuxFrame>()
|
||||
@@ -869,6 +972,10 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(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 'skill.list': return this.api.skills.list(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
Reference in New Issue
Block a user