diff --git a/.github/workflows/prcheck.yml b/.github/workflows/prcheck.yml index cd4cd643a..173448b67 100644 --- a/.github/workflows/prcheck.yml +++ b/.github/workflows/prcheck.yml @@ -100,6 +100,10 @@ jobs: - name: Test renderer run: pnpm run test:renderer + env: + # Reused jsdom workers accumulate heap across suites and breach the + # 4GB Node default on 16GB runners; 6GB keeps two workers in budget. + NODE_OPTIONS: --max-old-space-size=6144 build: runs-on: ubuntu-24.04 diff --git a/src/renderer/src/components/message/LiveDelegationToolCallCard.vue b/src/renderer/src/components/message/LiveDelegationToolCallCard.vue index 13af5d667..9bc83e9fa 100644 --- a/src/renderer/src/components/message/LiveDelegationToolCallCard.vue +++ b/src/renderer/src/components/message/LiveDelegationToolCallCard.vue @@ -14,6 +14,21 @@ {{ slotId }}

+ + {{ + permissionStatus === 'granted' ? t('toolCall.badge.allowed') : t('toolCall.badge.denied') + }} + () const emit = defineEmits<{ toggleDetails: [] }>() diff --git a/src/renderer/src/components/message/MessageBlockAction.vue b/src/renderer/src/components/message/MessageBlockAction.vue index 347229e0d..0993505ab 100644 --- a/src/renderer/src/components/message/MessageBlockAction.vue +++ b/src/renderer/src/components/message/MessageBlockAction.vue @@ -35,7 +35,24 @@ {{ t('components.messageBlockAction.continue') }}
+ + {{ + resolvedPermissionStatus === 'granted' + ? t('components.messageBlockPermissionRequest.granted') + : t('components.messageBlockPermissionRequest.denied') + }} +
+
{{ t('components.messageBlockAction.continued') }} @@ -48,7 +65,10 @@ import { useI18n } from 'vue-i18n' import { Icon } from '@iconify/vue' import { DcButton } from '@dc-ui/components/button' import { computed, ref, onMounted, onUnmounted } from 'vue' -import type { DisplayAssistantMessageBlock } from '@/features/chat-page/model/displayMessage' +import { + type DisplayAssistantMessageBlock, + getResolvedPermissionStatus +} from '@/features/chat-page/model/displayMessage' const { t } = useI18n() @@ -67,6 +87,7 @@ const progressTimer = ref(null) const currentTime = ref(Date.now()) const isReadOnly = computed(() => props.isReadOnly === true) const isRateLimitBlock = computed(() => props.block.action_type === 'rate_limit') +const resolvedPermissionStatus = computed(() => getResolvedPermissionStatus(props.block)) const elapsedSeconds = computed(() => { if (!isRateLimitBlock.value) { return 0 diff --git a/src/renderer/src/components/message/MessageBlockActivityGroup.vue b/src/renderer/src/components/message/MessageBlockActivityGroup.vue index 381957e6f..45810cce9 100644 --- a/src/renderer/src/components/message/MessageBlockActivityGroup.vue +++ b/src/renderer/src/components/message/MessageBlockActivityGroup.vue @@ -51,6 +51,9 @@ :thread-id="threadId" :read-only="readOnly" render-mode="tool-only" + :permission-status=" + block.tool_call?.id ? permissionStatusByToolCallId?.[block.tool_call.id] : undefined + " /> }>() const emit = defineEmits<{ diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue index f4e040b23..76781e144 100644 --- a/src/renderer/src/components/message/MessageBlockToolCall.vue +++ b/src/renderer/src/components/message/MessageBlockToolCall.vue @@ -8,6 +8,7 @@ :details-id="detailsId" :details-expanded="isExpanded" :read-only="readOnly" + :permission-status="permissionStatus" @toggle-details="toggleExpanded" />
+ + {{ + permissionStatus === 'granted' ? t('toolCall.badge.allowed') : t('toolCall.badge.denied') + }} + () type ExpansionSource = 'auto' | 'manual' | null diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index abb756075..2e265abeb 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -48,6 +48,7 @@ :reasoning-count="item.reasoningCount" :tool-call-count="item.toolCallCount" :read-only="isReadOnly" + :permission-status-by-tool-call-id="permissionStatusByToolCallId" @toggle-collapse="handleCollapseToggle" /> ' }, + 'shield-x': { + body: '' + }, 'shopping-bag': { body: '' }, diff --git a/src/renderer/src/lib/icons/icon-whitelist.generated.ts b/src/renderer/src/lib/icons/icon-whitelist.generated.ts index 043316e0b..e10c84ebd 100644 --- a/src/renderer/src/lib/icons/icon-whitelist.generated.ts +++ b/src/renderer/src/lib/icons/icon-whitelist.generated.ts @@ -223,6 +223,7 @@ export const GENERATED_ICON_WHITELIST: Record { expect(wrapper.emitted('continue')).toEqual([['s1', 'm1']]) }) + const createPermissionBlock = ( + status: DisplayAssistantMessageBlock['status'], + overrides: Partial = {} + ): DisplayAssistantMessageBlock => + createBlock({ + action_type: 'tool_call_permission', + status, + tool_call: { id: 'tc1', name: 'run_command' }, + ...overrides + }) + + it('renders granted permission outcome instead of the generic continued label', () => { + const wrapper = mount(MessageBlockAction, { + props: { + messageId: 'm1', + conversationId: 's1', + block: createPermissionBlock('granted') + } + }) + + const label = wrapper.find('[data-testid="permission-resolved-label"]') + expect(label.exists()).toBe(true) + expect(label.attributes('data-permission-status')).toBe('granted') + expect(wrapper.text()).toContain('components.messageBlockPermissionRequest.granted') + expect(wrapper.text()).not.toContain('components.messageBlockAction.continued') + }) + + it('renders denied permission outcome without any success affordance', () => { + const wrapper = mount(MessageBlockAction, { + props: { + messageId: 'm1', + conversationId: 's1', + block: createPermissionBlock('denied', { content: 'User denied the request.' }) + } + }) + + const label = wrapper.find('[data-testid="permission-resolved-label"]') + expect(label.attributes('data-permission-status')).toBe('denied') + expect(wrapper.text()).toContain('components.messageBlockPermissionRequest.denied') + expect(wrapper.text()).not.toContain('components.messageBlockAction.continued') + expect(wrapper.findAll('button')).toHaveLength(0) + }) + + it('keeps denied rendering identical in read-only history', () => { + const wrapper = mount(MessageBlockAction, { + props: { + messageId: 'm1', + conversationId: 's1', + isReadOnly: true, + block: createPermissionBlock('denied') + } + }) + + expect( + wrapper.find('[data-testid="permission-resolved-label"]').attributes('data-permission-status') + ).toBe('denied') + }) + + it('does not mark pending permission requests as resolved', () => { + const wrapper = mount(MessageBlockAction, { + props: { + messageId: 'm1', + conversationId: 's1', + block: createPermissionBlock('pending', { extra: { needsUserAction: true } }) + } + }) + + expect(wrapper.find('[data-testid="permission-resolved-label"]').exists()).toBe(false) + }) + it('renders a compact rate limit status block', () => { const wrapper = mount(MessageBlockAction, { props: { diff --git a/test/renderer/components/message/MessageBlockToolCall.test.ts b/test/renderer/components/message/MessageBlockToolCall.test.ts index f42994e07..22a05ae3d 100644 --- a/test/renderer/components/message/MessageBlockToolCall.test.ts +++ b/test/renderer/components/message/MessageBlockToolCall.test.ts @@ -1073,4 +1073,118 @@ describe('MessageBlockToolCall', () => { expect(selectSessionMock).toHaveBeenCalledWith('child-alpha') }) + + it('renders the resolved permission badge on the tool pill', () => { + const wrapper = mount(MessageBlockToolCall, { + props: { + block: createBlock(), + permissionStatus: 'granted' + } + }) + + const badge = wrapper.find('[data-testid="tool-call-permission-badge"]') + expect(badge.exists()).toBe(true) + expect(badge.attributes('data-permission-status')).toBe('granted') + expect(badge.text()).toBe('toolCall.badge.allowed') + }) + + it('renders a denied permission badge distinct from success styling', () => { + const wrapper = mount(MessageBlockToolCall, { + props: { + block: createBlock({ status: 'error' }), + permissionStatus: 'denied' + } + }) + + const badge = wrapper.find('[data-testid="tool-call-permission-badge"]') + expect(badge.attributes('data-permission-status')).toBe('denied') + expect(badge.text()).toBe('toolCall.badge.denied') + }) + + it('renders no permission badge when no permission outcome is associated', () => { + const wrapper = mount(MessageBlockToolCall, { + props: { + block: createBlock() + } + }) + + expect(wrapper.find('[data-testid="tool-call-permission-badge"]').exists()).toBe(false) + }) + + it('renders the granted outcome inside the live delegation card', () => { + const wrapper = mount(MessageBlockToolCall, { + props: { + threadId: 'parent-1', + permissionStatus: 'granted', + block: createBlock({ + extra: { toolSource: 'agent' }, + tool_call: { + id: 'spawn-1', + name: LIVE_DELEGATION_AGENT_TOOL_NAME, + server_name: LIVE_DELEGATION_AGENT_TOOL_SERVER_NAME, + params: JSON.stringify({ + operation: 'spawn', + slotId: 'reviewer', + title: 'Review architecture', + prompt: 'Inspect module boundaries.' + }), + response: JSON.stringify({ + delegation: { + schemaVersion: 1, + id: 'delegation-1', + parentSessionId: 'parent-1', + childSessionId: 'child-1', + slotId: 'reviewer', + targetAgentId: 'deepchat', + title: 'Review architecture', + status: 'running', + lastTurnSeq: 1, + createdAt: 10, + updatedAt: 20, + revision: 2, + summaryPreview: null, + errorPreview: null + }, + turns: [] + }) + } + }) + } + }) + + const card = wrapper.get('[data-testid="live-delegation-tool-card-delegation-1"]') + const badge = card.get('[data-testid="tool-call-permission-badge"]') + expect(badge.attributes('data-permission-status')).toBe('granted') + expect(badge.text()).toBe('toolCall.badge.allowed') + }) + + it('renders the denied outcome inside the live delegation card', () => { + const wrapper = mount(MessageBlockToolCall, { + props: { + threadId: 'parent-1', + permissionStatus: 'denied', + block: createBlock({ + status: 'error', + extra: { toolSource: 'agent' }, + tool_call: { + id: 'spawn-1', + name: LIVE_DELEGATION_AGENT_TOOL_NAME, + server_name: LIVE_DELEGATION_AGENT_TOOL_SERVER_NAME, + params: JSON.stringify({ + operation: 'spawn', + slotId: 'reviewer', + title: 'Review architecture', + prompt: 'Inspect module boundaries.' + }), + response: '' + } + }) + } + }) + + const card = wrapper.get('[data-testid="live-delegation-tool-card-pending"]') + const badge = card.get('[data-testid="tool-call-permission-badge"]') + expect(badge.attributes('data-permission-status')).toBe('denied') + expect(badge.text()).toBe('toolCall.badge.denied') + }) }) diff --git a/test/renderer/components/message/MessageItemAssistant.test.ts b/test/renderer/components/message/MessageItemAssistant.test.ts index c9cf9be1a..1b51549ff 100644 --- a/test/renderer/components/message/MessageItemAssistant.test.ts +++ b/test/renderer/components/message/MessageItemAssistant.test.ts @@ -742,4 +742,101 @@ describe('MessageItemAssistant', () => { expect(memoryActivity.openTurnMemories).not.toHaveBeenCalled() }) + + describe('resolved permission projection', () => { + const createPermissionActionBlock = ( + status: DisplayAssistantMessageBlock['status'], + toolCallId = 'tc1' + ): DisplayAssistantMessageBlock => ({ + type: 'action', + action_type: 'tool_call_permission', + status, + timestamp: 1, + tool_call: { id: toolCallId, name: 'run_command' } + }) + + const ToolCallStub = defineComponent({ + name: 'MessageBlockToolCall', + props: { + permissionStatus: { + type: String, + default: undefined + } + }, + template: + '
' + }) + + const mountWith = (content: DisplayAssistantMessageBlock[]) => + mount(MessageItemAssistant, { + props: { + message: createMessage('pending', content), + isCapturingImage: false, + isInGeneratingThread: true + }, + global: { + ...global, + stubs: { + ...global.stubs, + MessageBlockToolCall: ToolCallStub + } + } + }) + + it('merges the granted outcome into the tool card and hides the action card', () => { + const wrapper = mountWith([ + createPermissionActionBlock('granted'), + createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } }) + ]) + + expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(false) + expect( + wrapper.find('[data-testid="tool-call-stub"]').attributes('data-permission-status') + ).toBe('granted') + }) + + it('merges the denied outcome into the tool card', () => { + const wrapper = mountWith([ + createPermissionActionBlock('denied'), + createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } }) + ]) + + expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(false) + expect( + wrapper.find('[data-testid="tool-call-stub"]').attributes('data-permission-status') + ).toBe('denied') + }) + + it('merges the outcome only into the tool call with the matching id', () => { + const wrapper = mountWith([ + createPermissionActionBlock('granted', 'tc2'), + createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } }), + createToolCallBlock({ tool_call: { id: 'tc2', name: 'write_file' } }) + ]) + + expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(false) + const stubs = wrapper.findAll('[data-testid="tool-call-stub"]') + expect(stubs).toHaveLength(2) + expect(stubs[0].attributes('data-permission-status')).toBe('') + expect(stubs[1].attributes('data-permission-status')).toBe('granted') + }) + + it('keeps the standalone action card when the tool card is missing', () => { + const wrapper = mountWith([createPermissionActionBlock('denied', 'tc-missing')]) + + expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(true) + }) + + it('keeps pending permission action blocks visible and unmerged', () => { + const wrapper = mountWith([ + createPermissionActionBlock('pending'), + createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } }) + ]) + + expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(true) + expect( + wrapper.find('[data-testid="tool-call-stub"]').attributes('data-permission-status') + ).toBe('') + }) + }) })