mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix(desktop): preserve concurrent permission prompts
Track tool and Computer Use approvals by request ID so parallel prompts remain independently actionable. Reconcile resolution state across clients and reconnects, including stopped and completed turns. Refs: #975, #980 Tested: bun run check:desktop (1778 tests) Tested: bun run check:server (1482 tests) Tested: agent-browser concurrent permission smoke Not-tested: Windows packaged app manual smoke Confidence: high Scope-risk: moderate
This commit is contained in:
parent
840390de50
commit
25f19fad69
@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Button } from '../shared/Button'
|
||||
@ -68,7 +68,10 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
|
||||
const { respondToPermission } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const targetSessionId = sessionId ?? activeTabId
|
||||
const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined)
|
||||
const pendingRequest = useChatStore((s) => targetSessionId
|
||||
? listPendingPermissions(s.sessions[targetSessionId])
|
||||
.find((permission) => permission.toolUseId === toolUseId) ?? null
|
||||
: null)
|
||||
const t = useTranslation()
|
||||
const questions = parseInput(input)
|
||||
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
|
||||
@ -93,7 +96,6 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
|
||||
const hasStructuredAnswers = Object.keys(resultAnswers).length > 0
|
||||
const hasTerminalResult = hasStructuredAnswers || resultText.length > 0
|
||||
|
||||
const pendingRequest = pendingPermission?.toolUseId === toolUseId ? pendingPermission : null
|
||||
const answeredText = useMemo(() => {
|
||||
if (hasStructuredAnswers) {
|
||||
return questions
|
||||
|
||||
@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
||||
@ -1378,9 +1378,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
|
||||
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
|
||||
const activeAskUserQuestionToolUseId =
|
||||
sessionState?.pendingPermission?.toolName === 'AskUserQuestion'
|
||||
? sessionState.pendingPermission.toolUseId
|
||||
: null
|
||||
listPendingPermissions(sessionState)
|
||||
.find((permission) => permission.toolName === 'AskUserQuestion')?.toolUseId ?? null
|
||||
const shouldFollowContentResize =
|
||||
streamingText.trim().length > 0 ||
|
||||
chatState === 'streaming' ||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { getPendingPermission, useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
@ -122,9 +122,11 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
const { respondToPermission } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const targetSessionId = sessionId ?? activeTabId
|
||||
const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined)
|
||||
const pendingPermission = useChatStore((s) => targetSessionId
|
||||
? getPendingPermission(s.sessions[targetSessionId], requestId)
|
||||
: undefined)
|
||||
const t = useTranslation()
|
||||
const isPending = pendingPermission?.requestId === requestId
|
||||
const isPending = Boolean(pendingPermission)
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
|
||||
if (isExitPlanModeTool(toolName)) {
|
||||
@ -145,13 +147,18 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
const preview = renderPermissionPreview(toolName, input)
|
||||
const title = getPermissionTitle(toolName, input, t)
|
||||
const allowRawToggle = !preview
|
||||
const permissionContext = (details.primary || description || toolName).slice(0, 160)
|
||||
|
||||
return (
|
||||
<div className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
isPending
|
||||
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
|
||||
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
|
||||
}`}>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${title}: ${permissionContext}`}
|
||||
className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
isPending
|
||||
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
|
||||
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`flex items-center gap-3 px-4 py-3 ${
|
||||
isPending
|
||||
@ -163,6 +170,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
style={{ backgroundColor: `${meta.color}18` }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="material-symbols-outlined text-[18px]"
|
||||
style={{ color: meta.color }}
|
||||
>
|
||||
@ -176,7 +184,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
</span>
|
||||
{isPending && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-warning)]/15 text-[var(--color-warning)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
{t('permission.awaitingApproval')}
|
||||
</span>
|
||||
)}
|
||||
@ -198,7 +206,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
<div className="space-y-2">
|
||||
{details.primary && toolName !== 'Bash' ? (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
|
||||
folder_open
|
||||
</span>
|
||||
<span className="truncate">{details.primary}</span>
|
||||
@ -209,7 +217,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
) : details.primary ? (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
|
||||
{toolName === 'Glob' || toolName === 'Grep' ? 'search' : 'folder_open'}
|
||||
</span>
|
||||
<span className="truncate">{details.primary}</span>
|
||||
@ -227,7 +235,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="mt-2 flex cursor-pointer items-center gap-1 text-[11px] text-[var(--color-text-accent)] hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">
|
||||
{showRaw ? 'expand_less' : 'expand_more'}
|
||||
</span>
|
||||
{showRaw ? t('permission.hideDetails') : t('permission.showFullInput')}
|
||||
@ -247,9 +255,10 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label={`${t('permission.allow')}: ${permissionContext}`}
|
||||
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true)}
|
||||
icon={
|
||||
<span className="material-symbols-outlined text-[14px]">check</span>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">check</span>
|
||||
}
|
||||
>
|
||||
{t('permission.allow')}
|
||||
@ -257,9 +266,10 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${t('permission.allowForSession')}: ${permissionContext}`}
|
||||
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true, { rule: 'always' })}
|
||||
icon={
|
||||
<span className="material-symbols-outlined text-[14px]">verified</span>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">verified</span>
|
||||
}
|
||||
>
|
||||
{t('permission.allowForSession')}
|
||||
@ -268,9 +278,10 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
aria-label={`${t('permission.deny')}: ${permissionContext}`}
|
||||
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, false)}
|
||||
icon={
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">close</span>
|
||||
}
|
||||
>
|
||||
{t('permission.deny')}
|
||||
|
||||
@ -388,4 +388,60 @@ describe('chat blocks', () => {
|
||||
// fully render in jsdom, so we verify the DiffViewer wrapper is mounted
|
||||
expect(container.querySelector('[class*="rounded-[var(--radius-lg)]"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps every concurrent permission request actionable', () => {
|
||||
const firstPermission = {
|
||||
requestId: 'perm-read-1',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-read-1',
|
||||
input: { file_path: '/outside/one.ts' },
|
||||
}
|
||||
const secondPermission = {
|
||||
requestId: 'perm-read-2',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-read-2',
|
||||
input: { file_path: '/outside/two.ts' },
|
||||
}
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'active-tab': {
|
||||
messages: [],
|
||||
chatState: 'permission_pending',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: secondPermission,
|
||||
pendingPermissions: {
|
||||
[firstPermission.requestId]: firstPermission,
|
||||
[secondPermission.requestId]: secondPermission,
|
||||
},
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<>
|
||||
<PermissionDialog {...firstPermission} />
|
||||
<PermissionDialog {...secondPermission} />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('Awaiting approval')).toHaveLength(2)
|
||||
expect(screen.getByRole('group', { name: /\/outside\/one\.ts/ })).toBeTruthy()
|
||||
expect(screen.getByRole('group', { name: /\/outside\/two\.ts/ })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Allow: /outside/one.ts' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Allow: /outside/two.ts' })).toBeTruthy()
|
||||
expect(screen.queryByText('Responded')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2419,6 +2419,379 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps concurrent permission requests independently pending until each is answered', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
const store = useChatStore.getState()
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId: 'perm-read-1',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-read-1',
|
||||
input: { file_path: '/outside/one.ts' },
|
||||
})
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId: 'perm-read-2',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-read-2',
|
||||
input: { file_path: '/outside/two.ts' },
|
||||
})
|
||||
|
||||
let session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(Object.keys(session?.pendingPermissions ?? {})).toEqual([
|
||||
'perm-read-1',
|
||||
'perm-read-2',
|
||||
])
|
||||
expect(session?.messages.filter((message) => message.type === 'permission_request'))
|
||||
.toHaveLength(2)
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId: 'perm-read-1',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-read-1',
|
||||
input: { file_path: '/outside/one.ts' },
|
||||
})
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.messages.filter((message) => message.type === 'permission_request'))
|
||||
.toHaveLength(2)
|
||||
|
||||
store.respondToPermission(TEST_SESSION_ID, 'perm-read-2', true)
|
||||
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).not.toHaveProperty('perm-read-2')
|
||||
expect(session?.pendingPermissions).toHaveProperty('perm-read-1')
|
||||
expect(session?.pendingPermission?.requestId).toBe('perm-read-1')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tool-read-2',
|
||||
content: 'second file',
|
||||
isError: false,
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState)
|
||||
.toBe('permission_pending')
|
||||
|
||||
store.respondToPermission(TEST_SESSION_ID, 'perm-read-1', true)
|
||||
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).toEqual({})
|
||||
expect(session?.pendingPermission).toBeNull()
|
||||
expect(session?.chatState).toBe('tool_executing')
|
||||
})
|
||||
|
||||
it('removes replayed or cancelled requests when the server resolves them', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
const store = useChatStore.getState()
|
||||
const sendPermission = (requestId: string) => {
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId,
|
||||
toolName: 'Read',
|
||||
toolUseId: `tool-${requestId}`,
|
||||
input: { file_path: `/outside/${requestId}.ts` },
|
||||
})
|
||||
}
|
||||
|
||||
sendPermission('perm-read-1')
|
||||
sendPermission('perm-read-2')
|
||||
store.respondToPermission(TEST_SESSION_ID, 'perm-read-2', true)
|
||||
sendPermission('perm-read-2')
|
||||
|
||||
let session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).toHaveProperty('perm-read-2')
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_resolved',
|
||||
requestId: 'perm-read-2',
|
||||
permissionType: 'tool',
|
||||
allowed: true,
|
||||
})
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).not.toHaveProperty('perm-read-2')
|
||||
expect(session?.pendingPermissions).toHaveProperty('perm-read-1')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_resolved',
|
||||
requestId: 'perm-read-1',
|
||||
permissionType: 'tool',
|
||||
})
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).toEqual({})
|
||||
expect(session?.pendingPermission).toBeNull()
|
||||
expect(session?.chatState).toBe('thinking')
|
||||
})
|
||||
|
||||
it('reconciles stale tool and Computer Use requests from the reconnect snapshot', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
const store = useChatStore.getState()
|
||||
|
||||
for (const requestId of ['perm-read-1', 'perm-read-2']) {
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId,
|
||||
toolName: 'Read',
|
||||
toolUseId: `tool-${requestId}`,
|
||||
input: { file_path: `/outside/${requestId}.ts` },
|
||||
})
|
||||
}
|
||||
for (const requestId of ['cu-1', 'cu-2']) {
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'computer_use_permission_request',
|
||||
requestId,
|
||||
request: {
|
||||
requestId,
|
||||
reason: `Computer Use ${requestId}`,
|
||||
apps: [],
|
||||
requestedFlags: {},
|
||||
screenshotFiltering: 'native',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: ['perm-read-2'],
|
||||
computerUseRequestIds: ['cu-2'],
|
||||
turnActive: true,
|
||||
})
|
||||
|
||||
let session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(Object.keys(session?.pendingPermissions ?? {})).toEqual(['perm-read-2'])
|
||||
expect(session?.pendingPermission?.requestId).toBe('perm-read-2')
|
||||
expect(Object.keys(session?.pendingComputerUsePermissions ?? {})).toEqual(['cu-2'])
|
||||
expect(session?.pendingComputerUsePermission?.requestId).toBe('cu-2')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: [],
|
||||
computerUseRequestIds: [],
|
||||
turnActive: true,
|
||||
})
|
||||
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).toEqual({})
|
||||
expect(session?.pendingPermission).toBeNull()
|
||||
expect(session?.pendingComputerUsePermissions).toEqual({})
|
||||
expect(session?.pendingComputerUsePermission).toBeNull()
|
||||
expect(session?.chatState).toBe('thinking')
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: [],
|
||||
computerUseRequestIds: [],
|
||||
turnActive: false,
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('idle')
|
||||
})
|
||||
|
||||
it('preserves precise active chat states when a reconnect snapshot has no permissions', () => {
|
||||
for (const chatState of ['streaming', 'tool_executing', 'compacting'] as const) {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({ chatState }),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: [],
|
||||
computerUseRequestIds: [],
|
||||
turnActive: true,
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe(chatState)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps generic and Computer Use permissions pending in either arrival order', () => {
|
||||
const sendReadPermission = () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId: 'perm-read-1',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-read-1',
|
||||
input: { file_path: '/outside/one.ts' },
|
||||
})
|
||||
}
|
||||
const sendComputerUsePermission = () => {
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'computer_use_permission_request',
|
||||
requestId: 'cu-1',
|
||||
request: {
|
||||
requestId: 'cu-1',
|
||||
reason: 'Inspect another app',
|
||||
apps: [],
|
||||
requestedFlags: {},
|
||||
screenshotFiltering: 'native',
|
||||
},
|
||||
})
|
||||
}
|
||||
const allowComputerUse = {
|
||||
granted: [],
|
||||
denied: [],
|
||||
flags: {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: false,
|
||||
systemKeyCombos: false,
|
||||
},
|
||||
userConsented: true,
|
||||
}
|
||||
|
||||
for (const order of ['read-first', 'computer-first'] as const) {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
if (order === 'read-first') {
|
||||
sendReadPermission()
|
||||
sendComputerUsePermission()
|
||||
} else {
|
||||
sendComputerUsePermission()
|
||||
sendReadPermission()
|
||||
}
|
||||
|
||||
let session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).toHaveProperty('perm-read-1')
|
||||
expect(session?.pendingComputerUsePermissions).toHaveProperty('cu-1')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
|
||||
if (order === 'read-first') {
|
||||
useChatStore.getState().respondToPermission(TEST_SESSION_ID, 'perm-read-1', true)
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermissions).toEqual({})
|
||||
expect(session?.pendingComputerUsePermissions).toHaveProperty('cu-1')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
useChatStore.getState().respondToComputerUsePermission(
|
||||
TEST_SESSION_ID,
|
||||
'cu-1',
|
||||
allowComputerUse,
|
||||
)
|
||||
} else {
|
||||
useChatStore.getState().respondToComputerUsePermission(
|
||||
TEST_SESSION_ID,
|
||||
'cu-1',
|
||||
allowComputerUse,
|
||||
)
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingComputerUsePermissions).toEqual({})
|
||||
expect(session?.pendingPermissions).toHaveProperty('perm-read-1')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
useChatStore.getState().respondToPermission(TEST_SESSION_ID, 'perm-read-1', true)
|
||||
}
|
||||
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermission).toBeNull()
|
||||
expect(session?.pendingComputerUsePermission).toBeNull()
|
||||
expect(session?.chatState).toBe('tool_executing')
|
||||
}
|
||||
})
|
||||
|
||||
it('queues concurrent Computer Use permissions until each is answered', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
const store = useChatStore.getState()
|
||||
|
||||
for (const requestId of ['cu-1', 'cu-2']) {
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'computer_use_permission_request',
|
||||
requestId,
|
||||
request: {
|
||||
requestId,
|
||||
reason: `Computer Use ${requestId}`,
|
||||
apps: [],
|
||||
requestedFlags: {},
|
||||
screenshotFiltering: 'native',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(Object.keys(session?.pendingComputerUsePermissions ?? {})).toEqual([
|
||||
'cu-1',
|
||||
'cu-2',
|
||||
])
|
||||
expect(session?.pendingComputerUsePermission?.requestId).toBe('cu-1')
|
||||
|
||||
const response = {
|
||||
granted: [],
|
||||
denied: [],
|
||||
flags: {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: false,
|
||||
systemKeyCombos: false,
|
||||
},
|
||||
userConsented: true,
|
||||
}
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_resolved',
|
||||
requestId: 'cu-1',
|
||||
permissionType: 'computer_use',
|
||||
allowed: true,
|
||||
})
|
||||
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingComputerUsePermissions).not.toHaveProperty('cu-1')
|
||||
expect(session?.pendingComputerUsePermissions).toHaveProperty('cu-2')
|
||||
expect(session?.pendingComputerUsePermission?.requestId).toBe('cu-2')
|
||||
expect(session?.chatState).toBe('permission_pending')
|
||||
|
||||
store.respondToComputerUsePermission(TEST_SESSION_ID, 'cu-2', response)
|
||||
session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingComputerUsePermissions).toEqual({})
|
||||
expect(session?.pendingComputerUsePermission).toBeNull()
|
||||
expect(session?.chatState).toBe('tool_executing')
|
||||
})
|
||||
|
||||
it('shows the latest Computer Use payload when a request id is superseded', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
const store = useChatStore.getState()
|
||||
const sendRequest = (reason: string) => {
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'computer_use_permission_request',
|
||||
requestId: 'cu-1',
|
||||
request: {
|
||||
requestId: 'cu-1',
|
||||
reason,
|
||||
apps: [],
|
||||
requestedFlags: {},
|
||||
screenshotFiltering: 'native',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
sendRequest('OLD request')
|
||||
sendRequest('NEW request')
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingComputerUsePermission?.request.reason).toBe('NEW request')
|
||||
expect(session?.pendingComputerUsePermissions?.['cu-1']?.request.reason).toBe('NEW request')
|
||||
})
|
||||
|
||||
it('sends permission mode updates to the active session only', () => {
|
||||
useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
|
||||
@ -67,6 +67,23 @@ export type ComposerReferenceInsertion = {
|
||||
|
||||
export type ComposerPrefillMode = 'replace' | 'append'
|
||||
|
||||
export type PendingPermission = {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolUseId?: string
|
||||
input: unknown
|
||||
description?: string
|
||||
}
|
||||
|
||||
type PendingPermissions = Record<string, PendingPermission>
|
||||
|
||||
type PendingComputerUsePermission = {
|
||||
requestId: string
|
||||
request: ComputerUsePermissionRequest
|
||||
}
|
||||
|
||||
type PendingComputerUsePermissions = Record<string, PendingComputerUsePermission>
|
||||
|
||||
export type PerSessionState = {
|
||||
messages: UIMessage[]
|
||||
chatState: ChatState
|
||||
@ -78,17 +95,13 @@ export type PerSessionState = {
|
||||
activeToolUseId: string | null
|
||||
activeToolName: string | null
|
||||
activeThinkingId: string | null
|
||||
pendingPermission: {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolUseId?: string
|
||||
input: unknown
|
||||
description?: string
|
||||
} | null
|
||||
pendingComputerUsePermission: {
|
||||
requestId: string
|
||||
request: ComputerUsePermissionRequest
|
||||
} | null
|
||||
/** Most recently received request, retained as a compatibility mirror. */
|
||||
pendingPermission: PendingPermission | null
|
||||
/** Authoritative set of outstanding SDK permission requests, keyed by request id. */
|
||||
pendingPermissions?: PendingPermissions
|
||||
/** Currently displayed Computer Use request; remaining requests stay queued. */
|
||||
pendingComputerUsePermission: PendingComputerUsePermission | null
|
||||
pendingComputerUsePermissions?: PendingComputerUsePermissions
|
||||
tokenUsage: TokenUsage
|
||||
/**
|
||||
* Bumped each time a compact boundary arrives. The context usage indicator
|
||||
@ -137,7 +150,9 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
compactCount: 0,
|
||||
streamingResponseChars: 0,
|
||||
@ -166,6 +181,73 @@ function createDefaultSessionState(): PerSessionState {
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingPermissionRecord(
|
||||
session: Pick<PerSessionState, 'pendingPermission' | 'pendingPermissions'>,
|
||||
): PendingPermissions {
|
||||
const pendingPermissions = { ...(session.pendingPermissions ?? {}) }
|
||||
if (session.pendingPermission && !pendingPermissions[session.pendingPermission.requestId]) {
|
||||
pendingPermissions[session.pendingPermission.requestId] = session.pendingPermission
|
||||
}
|
||||
return pendingPermissions
|
||||
}
|
||||
|
||||
function getPendingComputerUsePermissionRecord(
|
||||
session: Pick<PerSessionState, 'pendingComputerUsePermission' | 'pendingComputerUsePermissions'>,
|
||||
): PendingComputerUsePermissions {
|
||||
const pendingPermissions = { ...(session.pendingComputerUsePermissions ?? {}) }
|
||||
if (
|
||||
session.pendingComputerUsePermission &&
|
||||
!pendingPermissions[session.pendingComputerUsePermission.requestId]
|
||||
) {
|
||||
pendingPermissions[session.pendingComputerUsePermission.requestId] =
|
||||
session.pendingComputerUsePermission
|
||||
}
|
||||
return pendingPermissions
|
||||
}
|
||||
|
||||
function getCurrentComputerUsePermission(
|
||||
pendingPermissions: PendingComputerUsePermissions,
|
||||
currentPermission: PendingComputerUsePermission | null,
|
||||
): PendingComputerUsePermission | null {
|
||||
return (currentPermission
|
||||
? pendingPermissions[currentPermission.requestId]
|
||||
: undefined) ?? Object.values(pendingPermissions)[0] ?? null
|
||||
}
|
||||
|
||||
function hasPendingPermissionRequests(session: PerSessionState): boolean {
|
||||
return Object.keys(getPendingPermissionRecord(session)).length > 0 ||
|
||||
Object.keys(getPendingComputerUsePermissionRecord(session)).length > 0
|
||||
}
|
||||
|
||||
function getChatStateAfterPermissionResolution(
|
||||
session: PerSessionState,
|
||||
hasRemainingPermissions: boolean,
|
||||
allowed: boolean | undefined,
|
||||
): ChatState {
|
||||
if (hasRemainingPermissions) return 'permission_pending'
|
||||
if (allowed === true) return 'tool_executing'
|
||||
if (allowed === false) return 'idle'
|
||||
return session.chatState === 'permission_pending' ? 'thinking' : session.chatState
|
||||
}
|
||||
|
||||
export function listPendingPermissions(
|
||||
session: Pick<PerSessionState, 'pendingPermission' | 'pendingPermissions'> | undefined,
|
||||
): PendingPermission[] {
|
||||
return session ? Object.values(getPendingPermissionRecord(session)) : []
|
||||
}
|
||||
|
||||
export function getPendingPermission(
|
||||
session: Pick<PerSessionState, 'pendingPermission' | 'pendingPermissions'> | undefined,
|
||||
requestId: string,
|
||||
): PendingPermission | undefined {
|
||||
if (!session) return undefined
|
||||
return session.pendingPermissions?.[requestId] ?? (
|
||||
session.pendingPermission?.requestId === requestId
|
||||
? session.pendingPermission
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
type ChatStore = {
|
||||
sessions: Record<string, PerSessionState>
|
||||
|
||||
@ -1152,7 +1234,22 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
...(options?.denyMessage ? { denyMessage: options.denyMessage } : {}),
|
||||
...(options?.permissionUpdates?.length ? { permissionUpdates: options.permissionUpdates } : {}),
|
||||
})
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
|
||||
set((s) => ({
|
||||
sessions: updateSessionIn(s.sessions, sessionId, (session) => {
|
||||
const pendingPermissions = getPendingPermissionRecord(session)
|
||||
delete pendingPermissions[requestId]
|
||||
const remainingPermissions = Object.values(pendingPermissions)
|
||||
|
||||
return {
|
||||
pendingPermissions,
|
||||
pendingPermission: remainingPermissions[remainingPermissions.length - 1] ?? null,
|
||||
chatState: remainingPermissions.length > 0 ||
|
||||
Object.keys(getPendingComputerUsePermissionRecord(session)).length > 0
|
||||
? 'permission_pending'
|
||||
: allowed ? 'tool_executing' : 'idle',
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
respondToComputerUsePermission: (sessionId, requestId, response) => {
|
||||
@ -1162,10 +1259,23 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
response,
|
||||
})
|
||||
set((s) => ({
|
||||
sessions: updateSessionIn(s.sessions, sessionId, () => ({
|
||||
pendingComputerUsePermission: null,
|
||||
chatState: response.userConsented === false ? 'idle' : 'tool_executing',
|
||||
})),
|
||||
sessions: updateSessionIn(s.sessions, sessionId, (session) => {
|
||||
const pendingComputerUsePermissions = getPendingComputerUsePermissionRecord(session)
|
||||
delete pendingComputerUsePermissions[requestId]
|
||||
const remainingPermissions = Object.values(pendingComputerUsePermissions)
|
||||
|
||||
return {
|
||||
pendingComputerUsePermissions,
|
||||
pendingComputerUsePermission: getCurrentComputerUsePermission(
|
||||
pendingComputerUsePermissions,
|
||||
session.pendingComputerUsePermission,
|
||||
),
|
||||
chatState: Object.keys(getPendingPermissionRecord(session)).length > 0 ||
|
||||
remainingPermissions.length > 0
|
||||
? 'permission_pending'
|
||||
: response.userConsented === false ? 'idle' : 'tool_executing',
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
@ -1211,7 +1321,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
streamingToolInput: '',
|
||||
statusVerb: '',
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
suppressNextTaskNotificationResponse: false,
|
||||
@ -1346,7 +1458,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
elapsedTimer: null,
|
||||
statusVerb: '',
|
||||
apiRetry: null,
|
||||
@ -1901,7 +2015,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
return {
|
||||
messages,
|
||||
...(stoppedTask ? { backgroundAgentTasks } : {}),
|
||||
chatState: 'thinking',
|
||||
chatState: hasPendingPermissionRequests(s)
|
||||
? 'permission_pending'
|
||||
: 'thinking',
|
||||
activeThinkingId: null,
|
||||
}
|
||||
})
|
||||
@ -1922,33 +2038,43 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
: '有一个工具请求正在等待允许。',
|
||||
target: { type: 'session', sessionId },
|
||||
})
|
||||
update((s) => ({
|
||||
pendingPermission: {
|
||||
update((s) => {
|
||||
const pendingPermission: PendingPermission = {
|
||||
requestId: msg.requestId,
|
||||
toolName: msg.toolName,
|
||||
toolUseId: msg.toolUseId,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
},
|
||||
pendingComputerUsePermission: null,
|
||||
chatState: 'permission_pending',
|
||||
activeThinkingId: null,
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
messages:
|
||||
msg.toolName === 'AskUserQuestion'
|
||||
? s.messages
|
||||
: [...s.messages, {
|
||||
id: nextId(),
|
||||
type: 'permission_request',
|
||||
requestId: msg.requestId,
|
||||
toolName: msg.toolName,
|
||||
toolUseId: msg.toolUseId,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}))
|
||||
}
|
||||
const pendingPermissions = {
|
||||
...getPendingPermissionRecord(s),
|
||||
[msg.requestId]: pendingPermission,
|
||||
}
|
||||
const hasPermissionMessage = s.messages.some((message) =>
|
||||
message.type === 'permission_request' && message.requestId === msg.requestId)
|
||||
|
||||
return {
|
||||
pendingPermission,
|
||||
pendingPermissions,
|
||||
chatState: 'permission_pending',
|
||||
activeThinkingId: null,
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
messages:
|
||||
msg.toolName === 'AskUserQuestion' || hasPermissionMessage
|
||||
? s.messages
|
||||
: [...s.messages, {
|
||||
id: nextId(),
|
||||
type: 'permission_request',
|
||||
requestId: msg.requestId,
|
||||
toolName: msg.toolName,
|
||||
toolUseId: msg.toolUseId,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'computer_use_permission_request':
|
||||
@ -1960,17 +2086,103 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
body: msg.request.reason || 'Computer Use 正在等待允许。',
|
||||
target: { type: 'session', sessionId },
|
||||
})
|
||||
update(() => ({
|
||||
pendingComputerUsePermission: {
|
||||
update((session) => {
|
||||
const pendingComputerUsePermission = {
|
||||
requestId: msg.requestId,
|
||||
request: msg.request,
|
||||
},
|
||||
pendingPermission: null,
|
||||
chatState: 'permission_pending',
|
||||
activeThinkingId: null,
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
}))
|
||||
}
|
||||
const pendingComputerUsePermissions = {
|
||||
...getPendingComputerUsePermissionRecord(session),
|
||||
[msg.requestId]: pendingComputerUsePermission,
|
||||
}
|
||||
return {
|
||||
pendingComputerUsePermission: getCurrentComputerUsePermission(
|
||||
pendingComputerUsePermissions,
|
||||
session.pendingComputerUsePermission,
|
||||
),
|
||||
pendingComputerUsePermissions,
|
||||
chatState: 'permission_pending',
|
||||
activeThinkingId: null,
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'permission_resolved':
|
||||
update((session) => {
|
||||
if (msg.permissionType === 'computer_use') {
|
||||
const pendingComputerUsePermissions = getPendingComputerUsePermissionRecord(session)
|
||||
if (!pendingComputerUsePermissions[msg.requestId]) return {}
|
||||
delete pendingComputerUsePermissions[msg.requestId]
|
||||
const remainingPermissions = Object.values(pendingComputerUsePermissions)
|
||||
|
||||
return {
|
||||
pendingComputerUsePermissions,
|
||||
pendingComputerUsePermission: getCurrentComputerUsePermission(
|
||||
pendingComputerUsePermissions,
|
||||
session.pendingComputerUsePermission,
|
||||
),
|
||||
chatState: getChatStateAfterPermissionResolution(
|
||||
session,
|
||||
Object.keys(getPendingPermissionRecord(session)).length > 0 ||
|
||||
remainingPermissions.length > 0,
|
||||
msg.allowed,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const pendingPermissions = getPendingPermissionRecord(session)
|
||||
if (!pendingPermissions[msg.requestId]) return {}
|
||||
delete pendingPermissions[msg.requestId]
|
||||
const remainingPermissions = Object.values(pendingPermissions)
|
||||
return {
|
||||
pendingPermissions,
|
||||
pendingPermission: remainingPermissions[remainingPermissions.length - 1] ?? null,
|
||||
chatState: getChatStateAfterPermissionResolution(
|
||||
session,
|
||||
remainingPermissions.length > 0 ||
|
||||
Object.keys(getPendingComputerUsePermissionRecord(session)).length > 0,
|
||||
msg.allowed,
|
||||
),
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'permission_requests_snapshot':
|
||||
update((session) => {
|
||||
const toolRequestIds = new Set(msg.toolRequestIds)
|
||||
const pendingPermissions = Object.fromEntries(
|
||||
Object.entries(getPendingPermissionRecord(session))
|
||||
.filter(([requestId]) => toolRequestIds.has(requestId)),
|
||||
)
|
||||
const computerUseRequestIds = new Set(msg.computerUseRequestIds)
|
||||
const pendingComputerUsePermissions = Object.fromEntries(
|
||||
Object.entries(getPendingComputerUsePermissionRecord(session))
|
||||
.filter(([requestId]) => computerUseRequestIds.has(requestId)),
|
||||
)
|
||||
const remainingPermissions = Object.values(pendingPermissions)
|
||||
const remainingComputerUsePermissions = Object.values(pendingComputerUsePermissions)
|
||||
const hasRemainingPermissions = remainingPermissions.length > 0 ||
|
||||
remainingComputerUsePermissions.length > 0
|
||||
|
||||
return {
|
||||
pendingPermissions,
|
||||
pendingPermission: remainingPermissions[remainingPermissions.length - 1] ?? null,
|
||||
pendingComputerUsePermissions,
|
||||
pendingComputerUsePermission: getCurrentComputerUsePermission(
|
||||
pendingComputerUsePermissions,
|
||||
session.pendingComputerUsePermission,
|
||||
),
|
||||
chatState: hasRemainingPermissions
|
||||
? 'permission_pending'
|
||||
: !msg.turnActive
|
||||
? 'idle'
|
||||
: session.chatState === 'idle' || session.chatState === 'permission_pending'
|
||||
? 'thinking'
|
||||
: session.chatState,
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'message_complete': {
|
||||
@ -1986,7 +2198,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
chatState: 'idle',
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
elapsedTimer: null,
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
@ -2024,7 +2238,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
chatState: 'idle',
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
elapsedTimer: null,
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
@ -2091,7 +2307,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
streamingText: '',
|
||||
statusVerb: '',
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
apiRetry: null,
|
||||
streamingFallback: null,
|
||||
suppressNextTaskNotificationResponse: false,
|
||||
@ -2152,7 +2370,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingPermissions: {},
|
||||
pendingComputerUsePermission: null,
|
||||
pendingComputerUsePermissions: {},
|
||||
chatState: 'idle',
|
||||
elapsedTimer: null,
|
||||
elapsedSeconds: 0,
|
||||
|
||||
@ -92,6 +92,18 @@ export type ServerMessage =
|
||||
requestId: string
|
||||
request: ComputerUsePermissionRequest
|
||||
}
|
||||
| {
|
||||
type: 'permission_resolved'
|
||||
requestId: string
|
||||
permissionType: 'tool' | 'computer_use'
|
||||
allowed?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'permission_requests_snapshot'
|
||||
toolRequestIds: string[]
|
||||
computerUseRequestIds: string[]
|
||||
turnActive: boolean
|
||||
}
|
||||
| { type: 'user_message_replay'; content: string }
|
||||
| { type: 'message_complete'; usage: TokenUsage }
|
||||
| { type: 'thinking'; text: string }
|
||||
|
||||
@ -68,6 +68,30 @@ describe('translateCliMessage usage mapping', () => {
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps SDK permission cancellation and response events to resolution messages', () => {
|
||||
expect(translateCliMessage({
|
||||
type: 'control_cancel_request',
|
||||
request_id: 'permission-1',
|
||||
}, 'session-1')).toEqual([{
|
||||
type: 'permission_resolved',
|
||||
requestId: 'permission-1',
|
||||
permissionType: 'tool',
|
||||
}])
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
request_id: 'permission-2',
|
||||
response: { behavior: 'deny' },
|
||||
},
|
||||
}, 'session-1')).toEqual([{
|
||||
type: 'permission_resolved',
|
||||
requestId: 'permission-2',
|
||||
permissionType: 'tool',
|
||||
allowed: false,
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket handler session isolation', () => {
|
||||
@ -154,6 +178,146 @@ describe('WebSocket handler session isolation', () => {
|
||||
},
|
||||
description: 'Answer questions?',
|
||||
})
|
||||
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: ['request-ask-1'],
|
||||
computerUseRequestIds: [],
|
||||
turnActive: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks and replays pending Computer Use requests when a client reconnects', async () => {
|
||||
const sessionId = `computer-use-reconnect-${crypto.randomUUID()}`
|
||||
const first = makeClientSocket(sessionId)
|
||||
const second = makeClientSocket(sessionId)
|
||||
const request = {
|
||||
requestId: 'cu-request-1',
|
||||
reason: 'Inspect another app',
|
||||
apps: [],
|
||||
requestedFlags: {},
|
||||
screenshotFiltering: 'native' as const,
|
||||
}
|
||||
const response = {
|
||||
granted: [],
|
||||
denied: [],
|
||||
flags: {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: false,
|
||||
systemKeyCombos: false,
|
||||
},
|
||||
userConsented: true,
|
||||
}
|
||||
|
||||
handleWebSocket.open(first)
|
||||
const approval = computerUseApprovalService.requestApproval(sessionId, request)
|
||||
expect(computerUseApprovalService.getPendingRequests(sessionId)).toEqual([request])
|
||||
|
||||
handleWebSocket.open(second)
|
||||
|
||||
expect(second.sent.map((payload) => JSON.parse(payload))).toEqual([
|
||||
{ type: 'connected', sessionId },
|
||||
{
|
||||
type: 'computer_use_permission_request',
|
||||
requestId: request.requestId,
|
||||
request,
|
||||
},
|
||||
{
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: [],
|
||||
computerUseRequestIds: [request.requestId],
|
||||
turnActive: false,
|
||||
},
|
||||
])
|
||||
|
||||
expect(computerUseApprovalService.resolveApproval(request.requestId, response)).toBe(true)
|
||||
await expect(approval).resolves.toEqual(response)
|
||||
expect(computerUseApprovalService.getPendingRequests(sessionId)).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a registered pre-send user turn active in the reconnect snapshot', () => {
|
||||
const sessionId = `pending-turn-reconnect-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
__registerPendingUserTurnForTests(sessionId)
|
||||
|
||||
handleWebSocket.open(ws)
|
||||
|
||||
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: [],
|
||||
computerUseRequestIds: [],
|
||||
turnActive: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not revive a stopped turn in the reconnect snapshot', () => {
|
||||
const sessionId = `stopped-turn-reconnect-${crypto.randomUUID()}`
|
||||
const first = makeClientSocket(sessionId)
|
||||
const second = makeClientSocket(sessionId)
|
||||
handleWebSocket.open(first)
|
||||
__markActiveTurnForTests(sessionId)
|
||||
|
||||
handleWebSocket.message(first, JSON.stringify({ type: 'stop_generation' }))
|
||||
handleWebSocket.open(second)
|
||||
|
||||
expect(second.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds: [],
|
||||
computerUseRequestIds: [],
|
||||
turnActive: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('broadcasts tool and Computer Use permission resolutions to every client', () => {
|
||||
const sessionId = `permission-resolution-${crypto.randomUUID()}`
|
||||
const first = makeClientSocket(sessionId)
|
||||
const second = makeClientSocket(sessionId)
|
||||
spyOn(conversationService, 'respondToPermission').mockReturnValue(true)
|
||||
spyOn(computerUseApprovalService, 'resolveApproval').mockReturnValue(true)
|
||||
|
||||
handleWebSocket.open(first)
|
||||
handleWebSocket.open(second)
|
||||
first.sent.length = 0
|
||||
second.sent.length = 0
|
||||
|
||||
handleWebSocket.message(first, JSON.stringify({
|
||||
type: 'permission_response',
|
||||
requestId: 'permission-1',
|
||||
allowed: true,
|
||||
}))
|
||||
|
||||
for (const ws of [first, second]) {
|
||||
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'permission_resolved',
|
||||
requestId: 'permission-1',
|
||||
permissionType: 'tool',
|
||||
allowed: true,
|
||||
})
|
||||
ws.sent.length = 0
|
||||
}
|
||||
|
||||
handleWebSocket.message(second, JSON.stringify({
|
||||
type: 'computer_use_permission_response',
|
||||
requestId: 'cu-1',
|
||||
response: {
|
||||
granted: [],
|
||||
denied: [],
|
||||
flags: {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: false,
|
||||
systemKeyCombos: false,
|
||||
},
|
||||
userConsented: false,
|
||||
},
|
||||
}))
|
||||
|
||||
for (const ws of [first, second]) {
|
||||
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'permission_resolved',
|
||||
requestId: 'cu-1',
|
||||
permissionType: 'computer_use',
|
||||
allowed: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps disconnected sessions alive longer while user input is pending', () => {
|
||||
|
||||
@ -3,6 +3,7 @@ import { sendToSession } from '../ws/handler.js'
|
||||
|
||||
type PendingApproval = {
|
||||
sessionId: string
|
||||
request: CuPermissionRequest
|
||||
resolve: (response: CuPermissionResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
@ -32,6 +33,7 @@ class ComputerUseApprovalService {
|
||||
|
||||
this.pending.set(request.requestId, {
|
||||
sessionId,
|
||||
request,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
@ -60,6 +62,12 @@ class ComputerUseApprovalService {
|
||||
return true
|
||||
}
|
||||
|
||||
getPendingRequests(sessionId: string): CuPermissionRequest[] {
|
||||
return Array.from(this.pending.values())
|
||||
.filter((pending) => pending.sessionId === sessionId)
|
||||
.map((pending) => pending.request)
|
||||
}
|
||||
|
||||
cancelSession(sessionId: string): void {
|
||||
for (const [requestId, pending] of this.pending.entries()) {
|
||||
if (pending.sessionId !== sessionId) continue
|
||||
|
||||
@ -62,6 +62,18 @@ export type ServerMessage =
|
||||
requestId: string
|
||||
request: ComputerUsePermissionRequest
|
||||
}
|
||||
| {
|
||||
type: 'permission_resolved'
|
||||
requestId: string
|
||||
permissionType: 'tool' | 'computer_use'
|
||||
allowed?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'permission_requests_snapshot'
|
||||
toolRequestIds: string[]
|
||||
computerUseRequestIds: string[]
|
||||
turnActive: boolean
|
||||
}
|
||||
| { type: 'user_message_replay'; content: string }
|
||||
| { type: 'message_complete'; usage: TokenUsage }
|
||||
| { type: 'thinking'; text: string }
|
||||
|
||||
@ -209,7 +209,15 @@ export const handleWebSocket = {
|
||||
|
||||
const msg: ServerMessage = { type: 'connected', sessionId }
|
||||
ws.send(JSON.stringify(msg))
|
||||
replayPendingPermissionRequests(ws, sessionId)
|
||||
const toolRequestIds = replayPendingPermissionRequests(ws, sessionId)
|
||||
const computerUseRequestIds = replayPendingComputerUsePermissionRequests(ws, sessionId)
|
||||
sendMessage(ws, {
|
||||
type: 'permission_requests_snapshot',
|
||||
toolRequestIds,
|
||||
computerUseRequestIds,
|
||||
turnActive:
|
||||
hasPendingOrActiveUserTurn(sessionId) && !sessionStopRequested.has(sessionId),
|
||||
})
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WebSocketData>, rawMessage: string | Buffer) {
|
||||
@ -621,7 +629,7 @@ function handlePermissionResponse(
|
||||
message: Extract<ClientMessage, { type: 'permission_response' }>
|
||||
) {
|
||||
const { sessionId } = ws.data
|
||||
conversationService.respondToPermission(
|
||||
const resolved = conversationService.respondToPermission(
|
||||
sessionId,
|
||||
message.requestId,
|
||||
message.allowed,
|
||||
@ -630,6 +638,14 @@ function handlePermissionResponse(
|
||||
message.denyMessage,
|
||||
message.permissionUpdates,
|
||||
)
|
||||
if (resolved) {
|
||||
sendToSession(sessionId, {
|
||||
type: 'permission_resolved',
|
||||
requestId: message.requestId,
|
||||
permissionType: 'tool',
|
||||
allowed: message.allowed,
|
||||
})
|
||||
}
|
||||
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
||||
}
|
||||
|
||||
@ -646,7 +662,14 @@ function handleComputerUsePermissionResponse(
|
||||
console.warn(
|
||||
`[WS] Ignored Computer Use permission response for unknown request ${message.requestId} from ${sessionId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
sendToSession(sessionId, {
|
||||
type: 'permission_resolved',
|
||||
requestId: message.requestId,
|
||||
permissionType: 'computer_use',
|
||||
allowed: message.response.userConsented !== false,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSetPermissionMode(
|
||||
@ -1729,8 +1752,32 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
return []
|
||||
}
|
||||
|
||||
case 'control_response':
|
||||
return []
|
||||
case 'control_cancel_request':
|
||||
return typeof cliMsg.request_id === 'string'
|
||||
? [{
|
||||
type: 'permission_resolved',
|
||||
requestId: cliMsg.request_id,
|
||||
permissionType: 'tool',
|
||||
}]
|
||||
: []
|
||||
|
||||
case 'control_response': {
|
||||
const requestId = typeof cliMsg.response?.request_id === 'string'
|
||||
? cliMsg.response.request_id
|
||||
: typeof cliMsg.request_id === 'string'
|
||||
? cliMsg.request_id
|
||||
: null
|
||||
if (!requestId) return []
|
||||
const behavior = cliMsg.response?.response?.behavior
|
||||
return [{
|
||||
type: 'permission_resolved',
|
||||
requestId,
|
||||
permissionType: 'tool',
|
||||
...(behavior === 'allow' || behavior === 'deny'
|
||||
? { allowed: behavior === 'allow' }
|
||||
: {}),
|
||||
}]
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
// 对话结果(成功或错误)
|
||||
@ -2131,8 +2178,9 @@ function cancelSessionDisconnectWatcher(sessionId: string): void {
|
||||
function replayPendingPermissionRequests(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
sessionId: string,
|
||||
): void {
|
||||
for (const request of conversationService.getPendingPermissionRequests(sessionId)) {
|
||||
): string[] {
|
||||
const requests = conversationService.getPendingPermissionRequests(sessionId)
|
||||
for (const request of requests) {
|
||||
sendMessage(ws, {
|
||||
type: 'permission_request',
|
||||
requestId: request.requestId,
|
||||
@ -2142,6 +2190,22 @@ function replayPendingPermissionRequests(
|
||||
...(request.description ? { description: request.description } : {}),
|
||||
})
|
||||
}
|
||||
return requests.map((request) => request.requestId)
|
||||
}
|
||||
|
||||
function replayPendingComputerUsePermissionRequests(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
sessionId: string,
|
||||
): string[] {
|
||||
const requests = computerUseApprovalService.getPendingRequests(sessionId)
|
||||
for (const request of requests) {
|
||||
sendMessage(ws, {
|
||||
type: 'computer_use_permission_request',
|
||||
requestId: request.requestId,
|
||||
request,
|
||||
})
|
||||
}
|
||||
return requests.map((request) => request.requestId)
|
||||
}
|
||||
|
||||
function getDesktopSlashCommand(content: string): ReturnType<typeof parseSlashCommand> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user