diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index f6f38a0dd..64600669c 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -104,4 +104,95 @@ describe('normalizeDecryptedMessage', () => { } expect(firstBlock.text).toContain('"foo": "bar"') }) + + it('converts user output to event', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { content: ' Background command stopped ' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + id: 'msg-1', + role: 'event', + isSidechain: false, + content: { type: 'message', message: 'Background command stopped' } + }) + }) + + it('treats without summary as sidechain (dropped by reducer)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'u3', + message: { content: ' killed ' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + }) + }) + + it('treats non-sidechain string user output as sidechain', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + isSidechain: false, + uuid: 'u1', + message: { content: 'This is a subagent prompt' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + }) + if (normalized?.role !== 'agent') throw new Error('Expected agent') + expect(normalized.content[0]).toMatchObject({ + type: 'sidechain', + prompt: 'This is a subagent prompt' + }) + }) + + it('treats user output as sidechain (dropped by reducer)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'u2', + message: { content: 'Some internal reminder' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + }) + }) }) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 18886e989..c9b50f8be 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -109,18 +109,6 @@ function normalizeUserOutput( const messageContent = message.content - // Skip system-injected messages that were logged as type:'user' but are - // not text the human actually typed (task notifications, command caveats, etc.) - if (typeof messageContent === 'string') { - const trimmed = messageContent.trimStart() - if ( - trimmed.startsWith('') || - trimmed.startsWith('') - ) { - return null - } - } - if (isSidechain && typeof messageContent === 'string') { return { id: messageId, @@ -132,15 +120,37 @@ function normalizeUserOutput( } } + // Handle system-injected messages that arrive as type:'user' through + // the agent output path. Real user text goes through normalizeUserRecord. if (typeof messageContent === 'string') { + // Convert to a visible event + const trimmed = messageContent.trimStart() + if (trimmed.startsWith('')) { + const summary = trimmed.match(/([\s\S]*?)<\/summary>/)?.[1]?.trim() + if (summary) { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { type: 'message', message: summary }, + isSidechain: false, + meta + } + } + } + + // All other string-content user messages in this path are + // system-injected (subagent prompts, system reminders, etc.). + // Treat as sidechain so the tracer can match it to a parent Task + // tool call; unmatched ones are harmlessly skipped by the reducer. return { id: messageId, localId, createdAt, - role: 'user', - isSidechain: false, - content: { type: 'text', text: messageContent }, - meta + role: 'agent', + isSidechain: true, + content: [{ type: 'sidechain', uuid, prompt: messageContent }] } } diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts new file mode 100644 index 000000000..e572ed452 --- /dev/null +++ b/web/src/chat/reducerTimeline.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { reduceTimeline } from './reducerTimeline' +import type { TracedMessage } from './tracer' + +function makeContext() { + return { + permissionsById: new Map(), + groups: new Map(), + consumedGroupIds: new Set(), + titleChangesByToolUseId: new Map(), + emittedTitleChangeToolUseIds: new Set() + } +} + +function makeUserMessage(text: string, overrides?: Partial): TracedMessage { + return { + id: 'msg-1', + localId: null, + createdAt: 1_700_000_000_000, + role: 'user', + content: { type: 'text', text }, + isSidechain: false, + ...overrides + } as TracedMessage +} + +describe('reduceTimeline', () => { + it('renders user text as user-text block', () => { + const text = 'Hello, this is a normal message' + const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext()) + + expect(blocks).toHaveLength(1) + expect(blocks[0].kind).toBe('user-text') + }) + + it('does not filter XML-like user text (filtering is in normalize layer)', () => { + const text = ' Some task ' + const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext()) + + expect(blocks).toHaveLength(1) + expect(blocks[0].kind).toBe('user-text') + }) +})