Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 changed files with 49683 additions and 8452 deletions

View File

@@ -12,7 +12,7 @@ export type {
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -80,6 +80,62 @@ const MARKDOWN_FIXTURE = [
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
/**
* SGR wrapper for the terminal output sample below: authoring the escapes as
* `\u001b` keeps literal control bytes out of this source file.
* @param code - the SGR parameter (an ANSI color or attribute number).
* @param body - the text the attribute applies to.
* @returns the body wrapped in the attribute and a reset.
*/
function sgr(code: number, body: string): string {
return `\u001b[${code}m${body}\u001b[0m`
}
/**
* Terminal output sample for fixture turn 65, authored to carry every feature
* the terminal card draws that turn 60's two prompt rows cannot reach:
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
* height cap collapses the middle. The exit status is authored separately in
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
* terminal card shows the exit as its own pill and leaving the marker in would
* render it twice (packages/bash/tool-bash/src/render.ts).
*/
const TERMINAL_OUTPUT_FIXTURE = [
sgr(1, 'Running 4 checks'),
`${sgr(32, '\u2713')} typecheck 1.82s`,
`${sgr(32, '\u2713')} lint 0.94s`,
`${sgr(32, '\u2713')} duplication 2.10s`,
`${sgr(31, '\u2717')} unit 8.41s`,
'',
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
` ${sgr(31, 'FAIL')} caps output at the configured line budget`,
' expected 16 lines, received 24',
'',
'NAME LINES BRANCHES FUNCTIONS UNCOVERED',
'TerminalBlock.tsx 100% 100% 100% -',
'ansi.ts 100% 100% 100% -',
'clipboard.ts 100% 100% 100% -',
'CodeBlock.tsx 98.4% 96.2% 100% 41-43',
'highlight.ts 100% 100% 100% -',
'Pill.tsx 100% 100% 100% -',
'StateDot.tsx 100% 100% 100% -',
'markdown/Markdown.tsx 100% 100% 100% -',
'',
sgr(31, '1 of 4 checks failed'),
].join('\n')
/**
* Exit status for each terminal sample, keyed by its output text. Authored
* alongside the sample rather than parsed back out of its trailing marker,
* which is the bash tool's own job and not something to reimplement here.
*/
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
// A two-line command, so the fixture covers the terminal card's one-row-per-
// command-line prompt (and that the card still marks the call exactly once).
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
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"}', '已写入')
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
// whose prompt label is its last segment, and a non-zero exit authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
// `[exit code: N]` marker, since the real presenter consumes that one out of
// the body. Named `bash`, so it also covers
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
// row) — the two chat-row shapes the terminal card renders in.
//
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// 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)
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(66, '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).
@@ -250,7 +322,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
return undefined
}
switch (name) {
// Both names present the same terminal card: `fx-bash` lands on the
// render-site fallback row, `bash` on the keyed BashRow registration.
case 'fx-bash':
case 'bash':
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
case 'fx-write':
return {
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
if (call === undefined) return undefined
switch (call.card) {
case 'terminal':
return { card: 'terminal', output: resultText, exitCode: 0 }
// The sample's own exit status, authored beside it: re-parsing the
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
// which this client-side fixture cannot import.
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
case 'diff':
return { card: 'diff', diffs: call.diffs }
case 'generic':
@@ -331,6 +409,44 @@ function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: b
}
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
/** Fixture preset table (the host PermissionService defaults). */
const PERMISSION_PRESETS: Record<string, { sandbox: string; approval: string; description: string }> = {
'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' },
}
/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */
function permissionSelectOf(
log: readonly SessionEvent[],
): { options: { value: string; name: string; description?: string }[]; currentValue: string } {
let preset: string | null = null
let sandbox = 'workspace-write'
let approval = 'ask'
for (const event of log) {
const item = event as { type: string; data: Record<string, unknown> }
if (item.type === 'permission/preset') preset = item.data['preset'] as string
else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string
else if (item.type === 'approval/policy') approval = item.data['policy'] as string
}
const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval
let currentValue = 'custom'
const folded = preset === null ? undefined : PERMISSION_PRESETS[preset]
if (preset !== null && folded !== undefined && matches(folded)) {
currentValue = preset
} else {
for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) {
if (matches(spec)) { currentValue = name; break }
}
}
return {
options: [
...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })),
...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [],
],
currentValue,
}
}
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
@@ -339,6 +455,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
}
// Always present (tool-todo unit composed): null when no plan stands.
values['todos'] = backscanTodos(log) ?? null
// Always present (permission service composed): the whole select.
values['permissions'] = permissionSelectOf(log)
// Always present (plan-mode unit composed): the {active, pending} view.
values['plan'] = planViewOf(log)
// Always present (GoalService unit composed): null before create / after clear.
@@ -373,6 +491,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
seq: event.seq,
}]
}
// Knob fold: any of the three whole-value knob events advances the select.
if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') {
return [{
type: 'session/projection',
sessionId: id,
key: 'permissions',
value: permissionSelectOf(log),
seq: event.seq,
}]
}
// The plan unit advances on its two folded event kinds.
if (type === 'plan/mode' || (type === 'command/run'
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
@@ -473,7 +601,7 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
@@ -607,8 +735,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return crumbs
}
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
/** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingApprovalId = 'fx-approval-1' as Extract<MuxFrame, { type: 'approval/requested' }>['approvalId']
/** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */
let approvalPending = true
const pendingQuestionRpcId = mint()
let questionPending = true
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
@@ -890,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
rename: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const { sessionId, title } = request.payload
const normalized = title.trim().replace(/\s+/g, ' ')
if (normalized.length === 0) {
return err(request, {
code: 'title-invalid',
message: 'session title must contain visible characters',
details: { sessionId },
})
}
// The append emits the session/event and its session/projection frame
// (host parallel); the unary response settles the caller first.
append(sessionId, {
type: 'session/title',
data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
})
const appended = logOf(sessionId).at(-1) as SessionEvent
return ok(request, { title: normalized, seq: appended.seq })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -998,6 +1150,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
)
return ok(request, { accepted: true as const })
},
updateQueue: request => err(request, {
code: 'queue-item-not-found',
message: 'fixture has no pending queue item',
details: { itemId: request.payload.itemId },
}),
cancel: (request) => {
const replay = replays.get(request.payload.sessionId)
if (replay !== undefined) {
@@ -1148,6 +1305,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
})
@@ -1164,6 +1322,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
// /permission mirrors the host handler: switch through the knob
// events (each append pushes a permissions projection frame).
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
}
return ok(request, { matched: true as const, commandId })
}
if (name === 'goal') {
// Host parallel: /goal with an objective creates (or reports) the
// current goal; the command lifecycle pair brackets the mutation.
@@ -1298,14 +1476,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
}
}
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (approvalPending) {
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: pendingApprovalId,
toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)',
},
})
}
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
@@ -1343,6 +1523,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
// audit correlation; a settled or unknown id is not-pending.
if (message.rpcId === pendingApprovalRpcId) {
if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const value = message.result.value as { approvalId?: unknown; outcome?: unknown }
if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approvalPending = false
emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome })
return Promise.resolve({ accepted: true })
}
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
@@ -1397,7 +1590,9 @@ export class FixtureApiClient extends AbstractApiClient {
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.rename': return this.api.sessions.rename(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(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)

View File

@@ -17,7 +17,7 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -45,6 +45,7 @@ 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: [] }))
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 }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
@@ -62,6 +63,7 @@ export class FakeApiClient implements IApiClient {
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -96,7 +98,9 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
expect(response.rpcId).toBe(request.rpcId)
if (!response.result.ok) throw new Error('list failed')
const commands = response.result.value.commands
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan'])
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
// input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo')
expect(echo?.input?.hint).toBeTruthy()

View File

@@ -72,8 +72,19 @@ describe('createFixtureApi', () => {
// Fixture composes the todos + plan units (host parallel when tool-todo
// and plan-mode are mounted): the empty-log values.
expect(empty.result.value).toEqual({
events: [], hasMore: false,
projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } },
events: [], hasMore: false, projections: { asOfSeq: -1, values: {
todos: null,
// Permission unit composed: the composition-default select.
permissions: {
options: [
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
],
currentValue: 'workspace-write',
},
plan: { active: false, pending: false },
goal: null,
} },
})
})
@@ -208,7 +219,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 7) abort.abort()
if (envelopes.length >= 8) abort.abort()
}
return envelopes
}
@@ -216,15 +227,16 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
// Projection baseline frames follow the subscribed frame (title + todos + plan + goal units).
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId)
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -311,6 +323,44 @@ describe('createFixtureApi', () => {
})).toEqual({ accepted: true })
})
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
const api = createFixtureApi()
// Discover the resident approval's stable rpcId from the mux baseline.
const abort = new AbortController()
const seen: { rpcId: string; frame: MuxFrame }[] = []
const consuming = (async () => {
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
})()
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
})
const requested = seen.find(s => s.frame.type === 'approval/requested')
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
const approvalId = requested.frame.approvalId
// Routed but malformed answers.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
// The real answer settles the question and broadcasts resolved.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
.toEqual({ accepted: true })
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
})
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
await consuming
const abort2 = new AbortController()
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
})
it('describe answers the fixture identity', async () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
@@ -414,6 +464,47 @@ describe('createFixtureApi', () => {
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
})
it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const framesPromise = (async () => {
const frames: MuxFrame[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
frames.push(envelope.payload)
if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort()
}
return frames
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' }))
expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' }))
if (!renamed.result.ok) throw new Error('rename failed')
expect(renamed.result.value.title).toBe('重命名')
const acceptedSeq = renamed.result.value.seq
// The response seq addresses the appended title event (the client plane
// has no session/title in its event union — titles ride the projection —
// so the event is located by seq and its payload checked structurally).
const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 }))
if (!history.result.ok) throw new Error('history failed')
const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq)
expect(appended?.event).toMatchObject({
type: 'session/title',
data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } },
})
// Beyond the subscribe-time baseline replay, the append emitted exactly
// one title projection frame carrying the new value at the response seq.
const frames = await framesPromise
const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名')
expect(titleFrames).toHaveLength(1)
expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq })
})
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
const api = createFixtureApi()
const wsid = 'fx-ws-fixture' as WorkspaceId