From 48f77630e51b09d07d21742181707693d3ee7c49 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 13:12:55 -0700 Subject: [PATCH 1/2] Fix fresh-agent tool result attribution --- .../fresh-agent/FreshAgentTranscript.tsx | 36 +++++- .../fresh-agent/FreshAgentTranscript.test.tsx | 122 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx index 7bb5ad2f..5008701f 100644 --- a/src/components/fresh-agent/FreshAgentTranscript.tsx +++ b/src/components/fresh-agent/FreshAgentTranscript.tsx @@ -224,6 +224,40 @@ function buildBlocks( return blocks } +function isSyntheticToolResultTurn(turn: FreshAgentTurn): boolean { + return turn.role === 'user' + && turn.items.length > 0 + && turn.items.every((item) => item.kind === 'tool_result') +} + +function appendTurnItems(previous: FreshAgentTurn, next: FreshAgentTurn): FreshAgentTurn { + return { + ...previous, + id: `${previous.id}:${next.id}`, + summary: [previous.summary, next.summary].filter(Boolean).join('\n\n'), + items: [...previous.items, ...next.items], + model: next.model ?? previous.model, + timestamp: next.timestamp ?? previous.timestamp, + } +} + +function coalesceSyntheticToolResultTurns(turns: FreshAgentTurn[]): FreshAgentTurn[] { + const coalesced: FreshAgentTurn[] = [] + for (const turn of turns) { + const previous = coalesced[coalesced.length - 1] + if (isSyntheticToolResultTurn(turn)) { + if (previous?.role === 'assistant') { + coalesced[coalesced.length - 1] = appendTurnItems(previous, turn) + } else { + coalesced.push({ ...turn, role: 'tool' }) + } + continue + } + coalesced.push(turn) + } + return coalesced +} + function filterTurnsForDisplay( turns: FreshAgentTurn[], options: TranscriptDisplayOptions, @@ -596,7 +630,7 @@ export const FreshAgentTranscript = forwardRef ( - filterTurnsForDisplay(turns, displayOptions, isStreaming) + filterTurnsForDisplay(coalesceSyntheticToolResultTurns(turns), displayOptions, isStreaming) ), [displayOptions, turns, isStreaming]) const liveActivityBlockId = useMemo( () => selectLiveActivityBlockId(displayTurns, isStreaming, displayOptions), diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index 7a756862..f72ba071 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -514,6 +514,128 @@ describe('FreshAgentTranscript', () => { expect(screen.getAllByLabelText('complete').length).toBeGreaterThanOrEqual(1) }) + it('folds Claude user-role tool results into the assistant activity instead of attributing them to You', () => { + const { container } = render( + , + ) + + const visibleHeaders = Array.from(container.querySelectorAll('.fresh-agent-turn-header')) + .map((node) => node.textContent?.trim()) + .filter(Boolean) + expect(visibleHeaders).toEqual(['You', 'Freshclaude']) + expect(container.querySelectorAll('[data-turn-role="user"] .fresh-agent-activity-strip')).toHaveLength(0) + expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('1 tool used') + + fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' })) + fireEvent.click(screen.getByRole('button', { name: 'Read tool call' })) + expect(screen.getByText('docs/plan.md')).toBeInTheDocument() + expect(screen.getByText('# Plan')).toBeInTheDocument() + }) + + it('coalesces adjacent Claude tool-use/result exchanges without rendering synthetic You turns', () => { + const { container } = render( + , + ) + + const visibleHeaders = Array.from(container.querySelectorAll('.fresh-agent-turn-header')) + .map((node) => node.textContent?.trim()) + .filter(Boolean) + expect(visibleHeaders).toEqual(['You', 'Freshclaude']) + expect(container.querySelectorAll('[data-turn-role="user"] .fresh-agent-activity-strip')).toHaveLength(0) + expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('2 tools used') + }) + it('shows the speaker label once for consecutive turns from the same role', () => { const { container } = render( Date: Sat, 13 Jun 2026 13:47:49 -0700 Subject: [PATCH 2/2] Filter Claude skill payload user turns --- server/coding-cli/utils.ts | 2 + .../fresh-agent/adapters/claude/normalize.ts | 110 +++++++++++++----- .../fresh-agent/FreshAgentTranscript.test.tsx | 7 +- test/unit/server/coding-cli/utils.test.ts | 20 ++++ .../fresh-agent/claude-normalize.test.ts | 74 +++++++++++- 5 files changed, 179 insertions(+), 34 deletions(-) diff --git a/server/coding-cli/utils.ts b/server/coding-cli/utils.ts index 817a8058..77154ab6 100644 --- a/server/coding-cli/utils.ts +++ b/server/coding-cli/utils.ts @@ -313,6 +313,8 @@ export function isSystemContext(text: string): boolean { if (/^<(environment_context|system_context|system|context|INSTRUCTIONS|user_instructions|permissions|collaboration_mode|skills_instructions)[>\s]/i.test(trimmed)) return true // Instruction file headers: "# AGENTS.md instructions for...", "# System", "# Instructions" if (/^#\s*(AGENTS|Instructions?|System)/i.test(trimmed)) return true + // Claude Code injects invoked skills as user-role context with the skill path first. + if (/^Base directory for this skill:\s+/i.test(trimmed)) return true // Bracketed agent mode instructions: [SUGGESTION MODE: ...], [REVIEW MODE: ...] if (/^\[[A-Z][A-Z_ ]*:/.test(trimmed)) return true // IDE context format: "# Context from my IDE setup:" diff --git a/server/fresh-agent/adapters/claude/normalize.ts b/server/fresh-agent/adapters/claude/normalize.ts index a54fed8a..de989438 100644 --- a/server/fresh-agent/adapters/claude/normalize.ts +++ b/server/fresh-agent/adapters/claude/normalize.ts @@ -6,6 +6,7 @@ import type { import type { QuestionDefinition, SdkSessionState } from '../../../sdk-bridge-types.js' import type { SdkSessionStatus } from '../../../sdk-bridge-types.js' import type { ContentBlock } from '../../../../shared/ws-protocol.js' +import { extractUserAuthoredText, isSystemContext } from '../../../coding-cli/utils.js' import { FreshAgentSnapshotSchema, FreshAgentTurnBodySchema, @@ -107,9 +108,63 @@ function blockSummary(blocks: ContentBlock[]): string { return '' } +function itemSummary(items: FreshAgentNormalizedItem[]): string { + const textItem = items.find((item): item is Extract => ( + item.kind === 'text' && item.text.trim().length > 0 + )) + if (textItem) return textItem.text.trim().slice(0, 140) + + const thinkingItem = items.find((item): item is Extract => ( + item.kind === 'thinking' && item.text.trim().length > 0 + )) + if (thinkingItem) return thinkingItem.text.trim().slice(0, 140) + + const toolItem = items.find((item): item is Extract => ( + item.kind === 'tool_use' + )) + if (toolItem) return toolItem.name.slice(0, 140) + + return '' +} + +function normalizeClaudeItem( + role: FreshAgentNormalizedTurn['role'], + turnId: string, + block: ContentBlock, + index: number, +): FreshAgentNormalizedItem | null { + const id = `${turnId}:item:${index}` + switch (block.type) { + case 'text': { + const text = role === 'user' ? extractUserAuthoredText(block.text) : block.text + return text ? { id, kind: 'text', text } : null + } + case 'thinking': + return { id, kind: 'thinking', text: block.thinking } + case 'tool_use': + return { id, kind: 'tool_use', toolUseId: block.id, name: block.name, input: block.input } + case 'tool_result': + return { + id, + kind: 'tool_result', + toolUseId: block.tool_use_id, + content: block.content, + isError: Boolean(block.is_error), + } + } +} + export function normalizeClaudeTurn( input: Pick, -): FreshAgentNormalizedTurn { +): FreshAgentNormalizedTurn | null { + const items = input.message.content + .map((block, index) => normalizeClaudeItem(input.message.role, input.turnId, block, index)) + .filter((item): item is FreshAgentNormalizedItem => item !== null) + + if (input.message.role === 'user' && items.length === 0) { + return null + } + return { id: input.turnId, turnId: input.turnId, @@ -119,29 +174,15 @@ export function normalizeClaudeTurn( role: input.message.role, ...(input.message.timestamp ? { timestamp: input.message.timestamp } : {}), ...(input.message.model ? { model: input.message.model } : {}), - summary: blockSummary(input.message.content), - items: input.message.content.map((block, index) => { - const id = `${input.turnId}:item:${index}` - switch (block.type) { - case 'text': - return { id, kind: 'text', text: block.text } - case 'thinking': - return { id, kind: 'thinking', text: block.thinking } - case 'tool_use': - return { id, kind: 'tool_use', toolUseId: block.id, name: block.name, input: block.input } - case 'tool_result': - return { - id, - kind: 'tool_result', - toolUseId: block.tool_use_id, - content: block.content, - isError: Boolean(block.is_error), - } - } - }), + summary: itemSummary(items) || blockSummary(input.message.content), + items, } } +function isSyntheticUserTimelineItem(item: Pick): boolean { + return item.role === 'user' && isSystemContext(item.summary) +} + function normalizePendingApprovals(liveSession?: SdkSessionState): FreshAgentPendingApproval[] { if (!liveSession) return [] return Array.from(liveSession.pendingPermissions.entries()).map(([requestId, approval]) => ({ @@ -169,7 +210,9 @@ export function normalizeClaudeThreadSnapshot(input: { status: SdkSessionStatus }): FreshAgentClaudeSnapshot { const sessionId = input.liveSession?.sessionId ?? input.resolved.liveSessionId ?? input.threadId - const turns = input.resolved.turns.map((turn) => normalizeClaudeTurn(turn)) + const turns = input.resolved.turns + .map((turn) => normalizeClaudeTurn(turn)) + .filter((turn): turn is FreshAgentNormalizedTurn => turn !== null) const inputTokens = input.liveSession?.totalInputTokens ?? 0 const outputTokens = input.liveSession?.totalOutputTokens ?? 0 return FreshAgentSnapshotSchema.parse({ @@ -178,7 +221,7 @@ export function normalizeClaudeThreadSnapshot(input: { threadId: input.threadId, sessionId, revision: input.resolved.revision, - latestTurnId: input.resolved.latestTurnId, + latestTurnId: turns.at(-1)?.turnId ?? null, status: input.status, capabilities: { send: true, @@ -216,13 +259,22 @@ export function normalizeClaudeTurnPage(input: { threadId: string page: ClaudeFreshAgentHistoryPage }): FreshAgentClaudeTurnPage { + const items = input.page.items.filter((item) => !isSyntheticUserTimelineItem(item)) + const bodies = input.page.bodies + ? Object.fromEntries( + Object.entries(input.page.bodies) + .map(([turnId, turn]) => [turnId, normalizeClaudeTurn(turn)] as const) + .filter((entry): entry is readonly [string, FreshAgentNormalizedTurn] => entry[1] !== null), + ) + : undefined + return FreshAgentTurnPageSchema.parse({ sessionType: 'freshclaude', provider: 'claude', threadId: input.threadId, revision: input.page.revision, nextCursor: input.page.nextCursor, - turns: input.page.items.map((item) => ({ + turns: items.map((item) => ({ id: item.turnId, turnId: item.turnId, messageId: item.messageId, @@ -233,11 +285,7 @@ export function normalizeClaudeTurnPage(input: { summary: item.summary, items: [], })), - ...(input.page.bodies ? { - bodies: Object.fromEntries( - Object.entries(input.page.bodies).map(([turnId, turn]) => [turnId, normalizeClaudeTurn(turn)]), - ), - } : {}), + ...(bodies ? { bodies } : {}), }) as FreshAgentClaudeTurnPage } @@ -246,8 +294,10 @@ export function normalizeClaudeTurnBody(input: { revision: number threadId: string }) { + const turn = normalizeClaudeTurn(input.turn) + if (!turn) return null return FreshAgentTurnBodySchema.parse({ - ...normalizeClaudeTurn(input.turn), + ...turn, sessionType: 'freshclaude', provider: 'claude', threadId: input.threadId, diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index f72ba071..aac32535 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -566,9 +566,8 @@ describe('FreshAgentTranscript', () => { expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('1 tool used') fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' })) - fireEvent.click(screen.getByRole('button', { name: 'Read tool call' })) expect(screen.getByText('docs/plan.md')).toBeInTheDocument() - expect(screen.getByText('# Plan')).toBeInTheDocument() + expect(container.querySelector('[data-tool-output]')).toHaveTextContent('# Plan') }) it('coalesces adjacent Claude tool-use/result exchanges without rendering synthetic You turns', () => { @@ -633,7 +632,9 @@ describe('FreshAgentTranscript', () => { .filter(Boolean) expect(visibleHeaders).toEqual(['You', 'Freshclaude']) expect(container.querySelectorAll('[data-turn-role="user"] .fresh-agent-activity-strip')).toHaveLength(0) - expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('2 tools used') + const strips = screen.getAllByRole('region', { name: 'Activity strip' }) + expect(strips).toHaveLength(2) + expect(strips.every((strip) => strip.textContent?.includes('1 tool used'))).toBe(true) }) it('shows the speaker label once for consecutive turns from the same role', () => { diff --git a/test/unit/server/coding-cli/utils.test.ts b/test/unit/server/coding-cli/utils.test.ts index 5a091df1..5c27445e 100644 --- a/test/unit/server/coding-cli/utils.test.ts +++ b/test/unit/server/coding-cli/utils.test.ts @@ -41,6 +41,16 @@ describe('isSystemContext()', () => { expect(isSystemContext('# agents.md instructions')).toBe(true) expect(isSystemContext('# INSTRUCTIONS')).toBe(true) }) + + it('detects Claude Code skill instruction payloads', () => { + expect(isSystemContext([ + 'Base directory for this skill: /home/dan/.claude/skills/fresheyes', + '', + '# Fresh Eyes - Independent Code Review', + '', + 'Invoke an independent model to perform a code review.', + ].join('\n'))).toBe(true) + }) }) describe('bracketed agent modes', () => { @@ -219,4 +229,14 @@ describe('extractUserAuthoredText()', () => { it('does not treat plain AGENTS instruction text as a user request', () => { expect(extractUserAuthoredText('# AGENTS.md instructions\n\nFollow these rules...')).toBeUndefined() }) + + it('does not treat Claude Code skill instruction payloads as user requests', () => { + expect(extractUserAuthoredText([ + 'Base directory for this skill: /home/dan/.claude/skills/fresheyes', + '', + '# Fresh Eyes - Independent Code Review', + '', + 'Invoke an independent model to perform a code review.', + ].join('\n'))).toBeUndefined() + }) }) diff --git a/test/unit/server/fresh-agent/claude-normalize.test.ts b/test/unit/server/fresh-agent/claude-normalize.test.ts index 30255f2e..df6f6274 100644 --- a/test/unit/server/fresh-agent/claude-normalize.test.ts +++ b/test/unit/server/fresh-agent/claude-normalize.test.ts @@ -51,7 +51,7 @@ describe('Claude fresh-agent normalization', () => { kind: 'resolved', queryId: 'sdk-claude-invariant', liveSessionId: 'sdk-claude-invariant', - timelineSessionId: '00000000-0000-4000-8000-000000000222', + timelineSessionId: '00000000-0000-8000-000000000222', readiness: 'merged', revision: 2, latestTurnId: 'turn:assistant-1', @@ -93,4 +93,76 @@ describe('Claude fresh-agent normalization', () => { { turnId: 'turn:assistant-1', messageId: 'assistant-1', role: 'assistant', summary: 'Inspecting the config now.' }, ]) }) + + it('drops Claude skill instruction payloads serialized as user messages', () => { + const resolved = makeClaudeRestoreResolution() + resolved.turns = [ + { + turnId: 'turn:user-request', + messageId: 'user-request', + ordinal: 0, + source: 'durable', + message: { + role: 'user', + timestamp: '2026-04-18T12:00:00.000Z', + content: [{ type: 'text', text: 'Review this plan with fresheyes.' }], + }, + }, + { + turnId: 'turn:assistant-tool', + messageId: 'assistant-tool', + ordinal: 1, + source: 'durable', + message: { + role: 'assistant', + timestamp: '2026-04-18T12:00:01.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-skill', + name: 'Skill', + input: { skill: 'fresheyes' }, + }, + ], + }, + }, + { + turnId: 'turn:skill-payload', + messageId: 'skill-payload', + ordinal: 2, + source: 'durable', + message: { + role: 'user', + timestamp: '2026-04-18T12:00:02.000Z', + content: [{ + type: 'text', + text: [ + 'Base directory for this skill: /home/dan/.claude/skills/fresheyes', + '', + '# Fresh Eyes - Independent Code Review', + '', + 'Invoke an independent model to perform a code review.', + ].join('\n'), + }], + }, + }, + ] + + const snapshot = normalizeClaudeThreadSnapshot({ + threadId: 'sdk-claude-1', + resolved, + status: 'idle', + }) + + expect(snapshot.turns.map((turn) => turn.turnId)).toEqual([ + 'turn:user-request', + 'turn:assistant-tool', + ]) + expect(snapshot.turns.map((turn) => turn.role)).toEqual(['user', 'assistant']) + expect(snapshot.turns[0]?.items).toEqual([ + expect.objectContaining({ kind: 'text', text: 'Review this plan with fresheyes.' }), + ]) + expect(JSON.stringify(snapshot.turns)).not.toContain('Base directory for this skill') + expect(JSON.stringify(snapshot.turns)).not.toContain('Fresh Eyes - Independent Code Review') + }) })