Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md
This commit is contained in:
@@ -334,6 +334,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')
|
||||
@@ -342,6 +380,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.
|
||||
@@ -376,6 +416,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')) {
|
||||
@@ -610,8 +660,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'] = [
|
||||
@@ -1151,6 +1204,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]' } },
|
||||
],
|
||||
})
|
||||
@@ -1167,6 +1221,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.
|
||||
@@ -1301,14 +1375,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,
|
||||
@@ -1346,6 +1422,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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user