diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts index 55a580f84..a3c15eb97 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts @@ -23,6 +23,14 @@ function assistantChunk(text: string, sequence: number): AcpMessage { }; } +function reasoningChunk(text: string, sequence: number): AcpMessage { + return { + ...assistantChunk(text, sequence), + eventType: ACP_ENVELOPE_EVENT_TYPES.AssistantThoughtChunk, + kind: 'reasoning', + }; +} + function toolCallUpdate(sequence: number): AcpMessage { return { id: `opencode-server:${sequence}`, @@ -72,6 +80,38 @@ function subagentActivityUpdate( } describe('AcpProtocolService', () => { + it('separates adjacent bold headings across reasoning chunks', () => { + const service = new AcpProtocolService(); + let messages = service.applyOutputEvent( + [], + reasoningChunk('**Clarifying boundaries***', 1), + )!.acpMessages; + + messages = service.applyOutputEvent( + messages, + reasoningChunk('*Assessing precision**', 2), + )!.acpMessages; + + service.reset(); + service.rebindMessages(messages); + messages = service.applyOutputEvent( + messages, + reasoningChunk('****Checking gaps**', 3), + )!.acpMessages; + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + partial: true, + rawText: + '**Clarifying boundaries****Assessing precision****Checking gaps**', + text: [ + '**Clarifying boundaries**', + '**Assessing precision**', + '**Checking gaps**', + ].join('\n\n'), + }); + }); + it('continues a partial assistant stream after active stream state is rebuilt', () => { const service = new AcpProtocolService(); let messages = service.applyOutputEvent( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts index 6731bbb8d..1b484a106 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts @@ -21,6 +21,7 @@ import { getImageUrisFromContentBlocks, getProviderRetryNoticeFromMessageData, inferAcpMessageKind, + normalizeAcpReasoningText, normalizeTranscriptUserText, normalizePlanPayload, parseAcpRequestUserInputPayload, @@ -317,6 +318,8 @@ export function toAcpUiMessage( case 'reasoning': return { ...base, + text: normalizeAcpReasoningText(normalized.text ?? ''), + rawText: normalized.text ?? '', role: 'assistant', kind: 'reasoning', data: payloadRecord, @@ -1281,10 +1284,23 @@ export class AcpProtocolService { const existing = messages[idx]! as AcpOtherUiMessage; const next = messages.slice(); + const existingText = existing.rawText ?? existing.text ?? ''; + const candidateText = candidate.rawText ?? candidate.text ?? ''; + const hasOverlappingBoldBoundary = + idPrefix === 'reasoning' && + existingText.endsWith('**') && + candidateText.startsWith('****'); + const combinedText = + existingText + + (hasOverlappingBoldBoundary ? candidateText.slice(2) : candidateText); next[idx] = { ...existing, ts: event.ts, - text: (existing.text ?? '') + (candidate.text ?? ''), + text: + idPrefix === 'reasoning' + ? normalizeAcpReasoningText(combinedText) + : combinedText, + ...(idPrefix === 'reasoning' ? { rawText: combinedText } : {}), partial: true, data: event.payload, }; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts index 37d77218b..f71643916 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts @@ -55,6 +55,8 @@ export interface AcpTodoSectionUiMessage extends AcpUiMessageBase { export interface AcpOtherUiMessage extends AcpUiMessageBase { kind: Exclude; data: Record; + /** Source chunks before reasoning-only display normalization. */ + rawText?: string; } export type AcpUiMessage = diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts index 3da26db0e..b451ab22d 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts @@ -589,6 +589,10 @@ describe('OpenCodeServerHarness', () => { it('persists a consolidated AssistantThought from reasoning parts', async () => { const { client, harness } = createHarness(); + const rawReasoning = + '**Clarifying boundaries****Assessing precision****Checking gaps**'; + const normalizedReasoning = + '**Clarifying boundaries**\n\n**Assessing precision**\n\n**Checking gaps**'; const runtimeOutputEvents: AcpMessage[] = []; const persistedEnvelopes: AcpPersistedEnvelope[] = []; const turnCompletedEvents: AcpTurnCompletedEvent[] = []; @@ -627,9 +631,9 @@ describe('OpenCodeServerHarness', () => { sessionID: 'ses_1', messageID: 'msg_1', type: 'reasoning', - text: 'Because reasons.', + text: rawReasoning, }, - delta: 'Because reasons.', + delta: rawReasoning, }, }); @@ -648,7 +652,7 @@ describe('OpenCodeServerHarness', () => { sessionID: 'ses_1', messageID: 'msg_1', type: 'reasoning', - text: 'Because reasons.', + text: rawReasoning, }, { id: 'part_1', @@ -686,7 +690,7 @@ describe('OpenCodeServerHarness', () => { (event) => event.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantThoughtChunk && - event.text === 'Because reasons.', + event.text === rawReasoning, ), ).toBe(true); @@ -704,7 +708,7 @@ describe('OpenCodeServerHarness', () => { (envelope) => envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantThought, ); - expect(thought?.payload.text).toBe('Because reasons.'); + expect(thought?.payload.text).toBe(normalizedReasoning); // ...and it lands before the answer message in the transcript. const thoughtIndex = persistedEnvelopes.findIndex( diff --git a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts index de54f9293..6e77a09e1 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts @@ -10,6 +10,7 @@ import { asRecord, asString, buildAcpRequestUserInputRequestId, + normalizeAcpReasoningText, parseAcpFlattenedMcpToolName, OPENCODE_ARCHITECT_AGENT, OPENCODE_BUILD_AGENT, @@ -607,11 +608,13 @@ function extractAssistantText(message: OpenCodeSessionMessage): string { } function extractAssistantReasoning(message: OpenCodeSessionMessage): string { - return message.parts - .filter((part) => part.type === 'reasoning') - .map(extractPartText) - .filter((text) => text.length > 0) - .join('\n'); + return normalizeAcpReasoningText( + message.parts + .filter((part) => part.type === 'reasoning') + .map(extractPartText) + .filter((text) => text.length > 0) + .join('\n'), + ); } function parseOpenCodeMessageRole(value: unknown): OpenCodeMessageRole | null { diff --git a/packages/types/src/__tests__/acp.test.ts b/packages/types/src/__tests__/acp.test.ts index 23cc97e16..10ee91d0f 100644 --- a/packages/types/src/__tests__/acp.test.ts +++ b/packages/types/src/__tests__/acp.test.ts @@ -6,6 +6,7 @@ import { parseAcpTaskCancelledPayload, type AcpRequestUserInputQuestion, isLinkedReviewResultsMessage, + normalizeAcpReasoningText, normalizeTranscriptUserText, parseLinkedReviewResults, parseAcpRequestUserInputAnswerReply, @@ -21,6 +22,27 @@ import { ACP_API_TOOL_OUTPUT_MAX_CHARS, } from '../acp'; +describe('normalizeAcpReasoningText', () => { + it('separates adjacent bold reasoning headings', () => { + expect( + normalizeAcpReasoningText( + '**Clarifying boundaries****Assessing precision****Checking gaps**', + ), + ).toBe( + '**Clarifying boundaries**\n\n**Assessing precision**\n\n**Checking gaps**', + ); + }); + + it.each([ + 'First\n\n****\n\n**Second**', + 'The requested **read****write** permissions are required.', + '**read****write** permissions are required.', + 'Permissions: **read****write**', + ])('preserves non-heading Markdown: %s', (markdown) => { + expect(normalizeAcpReasoningText(markdown)).toBe(markdown); + }); +}); + describe('wrapOutOfBandContext', () => { it('wraps messages with escaped content and a sent_at attribute', () => { const block = wrapOutOfBandContext([ diff --git a/packages/types/src/acp.ts b/packages/types/src/acp.ts index 2b1fb6951..50680e74a 100644 --- a/packages/types/src/acp.ts +++ b/packages/types/src/acp.ts @@ -53,6 +53,25 @@ export type AcpLiveEventType = /** All known Roomote runtime event types — envelope (persisted) + live-only (streamed). */ export type AcpEventType = AcpEnvelopeEventType | AcpLiveEventType; +/** Restore separation between bold reasoning headings concatenated by a provider. */ +export function normalizeAcpReasoningText(text: string): string { + return text.replace(/[^\r\n]+/g, (line) => { + if (!line.startsWith('**') || !line.endsWith('**')) { + return line; + } + + const headings = line.slice(2, -2).split('****'); + if ( + headings.length < 2 || + headings.some((heading) => heading.length === 0 || heading.includes('**')) + ) { + return line; + } + + return `**${headings.join('**\n\n**')}**`; + }); +} + export const ACP_LOGICAL_EVENT_ID_KEY = 'logicalEventId' as const; export interface AcpLogicalEventIdParts {