From a1893484cdbbab7f696731785ddb112866d12363 Mon Sep 17 00:00:00 2001 From: axisrow Date: Thu, 8 Oct 2026 19:10:50 +0800 Subject: [PATCH] fix(#59): classify mixed relay-XML content as user text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user prompt starting with a wrapper plus real text (a trailing question, quoted XML) was swallowed as a teammate relay and the user's words were dropped from display. Relay detection now requires the whole content to be complete relay blocks: TEAMMATE_BLOCK_RE moves to the canonical core and is shared with teammateMessageParser, so classification and card rendering cannot drift. isTeammateRelayLine applies the whole-content rule across text blocks; the subagent display path only routes pure relays to teammate cards, mixed content keeps its text as subagent input. Pure relays, transcript-turn semantics and the hook canon (#55) are unchanged; malformed wrappers (unclosed, old-style id='x' — unconfirmed in real transcripts) stay visible as user text instead of vanishing. Co-Authored-By: Claude Code --- eslint.config.js | 1 + scripts/turn-accounting.d.mts | 12 +++++-- scripts/turn-accounting.mjs | 36 +++++++++++++++----- src/renderer/utils/displayItemBuilder.ts | 5 ++- src/shared/turnAccounting.ts | 1 + src/shared/utils/teammateMessageParser.ts | 8 +++-- test/fixtures/turn-accounting/session.jsonl | 2 +- test/scripts/turnBudgetHookScript.test.ts | 14 ++++---- test/shared/turnAccounting.relay.test.ts | 37 ++++++++++++++------- 9 files changed, 82 insertions(+), 34 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index e1c32300..a858905d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -564,6 +564,7 @@ export default defineConfig([ 'lastAssistantTotalTokens', 'readConfig', 'SYSTEM_OUTPUT_TAGS', + 'TEAMMATE_BLOCK_RE', ], }, ], diff --git a/scripts/turn-accounting.d.mts b/scripts/turn-accounting.d.mts index 06ecb95e..45c7a2bf 100644 --- a/scripts/turn-accounting.d.mts +++ b/scripts/turn-accounting.d.mts @@ -49,8 +49,16 @@ export declare const SYSTEM_OUTPUT_TAGS: string[]; /** Canonical user-turn predicate: port of isParsedUserChunkMessage. */ export declare function isUserChunkLine(m: AccountableMessage): boolean; -/** Teammate relay: non-meta user line carrying a wrapper - * — transcript turn input but NOT a hook-turn boundary (issue #55). */ +/** Complete ... block — shared with + * the display parser (teammateMessageParser.ts), one definition (issue #59). */ +export declare const TEAMMATE_BLOCK_RE: RegExp; + +/** Text minus every complete relay block — the remainder is user text. */ +export declare function stripTeammateBlocks(t: string): string; + +/** Teammate relay: non-meta user line consisting solely of complete + * wrappers — transcript turn input but NOT a hook-turn + * boundary (issue #55); mixed content is a user message (issue #59). */ export declare function isTeammateRelayLine(m: AccountableMessage): boolean; /** Transcript turn input: a real user message or a teammate relay. */ diff --git a/scripts/turn-accounting.mjs b/scripts/turn-accounting.mjs index 277450b3..2d73755f 100644 --- a/scripts/turn-accounting.mjs +++ b/scripts/turn-accounting.mjs @@ -60,9 +60,24 @@ export function lastAssistantTotalTokens(responses) { return 0; } -/** Simplified teammate-message wrapper detection. */ +/** Complete ... block — the exact + * regex the display parser (src/shared/utils/teammateMessageParser.ts) + * re-uses, so relay detection and card rendering cannot drift. */ +export const TEAMMATE_BLOCK_RE = + /]*)>([\s\S]*?)<\/teammate-message>/g; + +/** Text minus every complete relay block (issue #59): whatever remains is + * user text, so mixed content stays a user message. */ +export function stripTeammateBlocks(t) { + return t.replace(new RegExp(TEAMMATE_BLOCK_RE.source, TEAMMATE_BLOCK_RE.flags), ''); +} + +/** Relay text = ONLY complete relay wrappers (plus whitespace). A prompt that + * merely starts with a wrapper (quote, trailing question) is user text — + * not a relay. */ function isTeammateText(t) { - return t.startsWith(' wrapper — inter-agent traffic. The single relay - * definition: isUserChunkLine delegates its relay exclusion here, so the - * two predicates cannot drift. Transcript turn input (isTranscriptTurnLine) - * but NOT a hook-turn boundary (isUserChunkLine) — the relay bills to the - * leader's turn (issue #55). + * Teammate relay: a non-meta user line whose content consists solely of + * complete wrappers — inter-agent traffic. Mixed + * content (a relay block plus user text, issue #59) is a user message, so + * the user's words survive. The single relay definition: isUserChunkLine + * delegates its relay exclusion here, so the two predicates cannot drift. + * Transcript turn input (isTranscriptTurnLine) but NOT a hook-turn boundary + * (isUserChunkLine) — the relay bills to the leader's turn (issue #55). */ export function isTeammateRelayLine(m) { if (m.type !== 'user' || m.isMeta === true) return false; @@ -141,7 +157,9 @@ export function isTeammateRelayLine(m) { : Array.isArray(c) ? c.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text) : []; - return texts.some((t) => isTeammateText(t.trim())); + // whole-content rule across blocks: a relay block next to user text keeps + // the line a user message + return isTeammateText(texts.join('\n')); } /** diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index 1a5447fa..b1b77fbc 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -4,6 +4,7 @@ * Creates a flat chronological list of display items for the AI Group UI. */ +import { isTeammateRelayLine } from '@shared/turnAccounting'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers'; @@ -437,7 +438,9 @@ export function buildDisplayItemsFromMessages( .join('') : ''; const parsedBlocks = parseAllTeammateMessages(rawText); - if (parsedBlocks.length > 0) { + // cards only for pure relays; mixed content keeps its user text + // (subagent_input below) instead of dropping it — issue #59 + if (parsedBlocks.length > 0 && isTeammateRelayLine(msg)) { for (const parsed of parsedBlocks) { displayItems.push({ type: 'teammate_message', diff --git a/src/shared/turnAccounting.ts b/src/shared/turnAccounting.ts index d83dbf58..3f13e9a8 100644 --- a/src/shared/turnAccounting.ts +++ b/src/shared/turnAccounting.ts @@ -23,4 +23,5 @@ export { isUserChunkLine, lastAssistantTotalTokens, SYSTEM_OUTPUT_TAGS, + TEAMMATE_BLOCK_RE, } from '../../scripts/turn-accounting.mjs'; diff --git a/src/shared/utils/teammateMessageParser.ts b/src/shared/utils/teammateMessageParser.ts index 574b747e..4fa9197b 100644 --- a/src/shared/utils/teammateMessageParser.ts +++ b/src/shared/utils/teammateMessageParser.ts @@ -6,6 +6,8 @@ * Pure function for cross-process use (renderer needs it in displayItemBuilder). */ +import { TEAMMATE_BLOCK_RE } from '@shared/turnAccounting'; + export interface ParsedTeammateContent { teammateId: string; color: string; @@ -16,10 +18,10 @@ export interface ParsedTeammateContent { /** * Regex to match a single block (non-greedy content). * Captures: [1] teammate_id, [2] remaining attributes string, [3] inner content + * + * Defined once in the canonical accounting core (turn-accounting.mjs) so + * relay classification and card rendering share one definition (issue #59). */ -const TEAMMATE_BLOCK_RE = - /]*)>([\s\S]*?)<\/teammate-message>/g; - const COLOR_RE = /color="([^"]*)"/; const SUMMARY_RE = /summary="([^"]*)"/; diff --git a/test/fixtures/turn-accounting/session.jsonl b/test/fixtures/turn-accounting/session.jsonl index 745d3c99..735f696c 100644 --- a/test/fixtures/turn-accounting/session.jsonl +++ b/test/fixtures/turn-accounting/session.jsonl @@ -8,7 +8,7 @@ {"type":"user","isMeta":true,"message":{"role":"user","content":[{"type":"tool_result","content":"x"}]}} {"type":"user","message":{"role":"user","content":"ok"}} {"type":"assistant","requestId":"req_d","message":{"id":"msg_d","content":[],"usage":{"input_tokens":400,"cache_read_input_tokens":4000,"cache_creation_input_tokens":0,"output_tokens":30}}} -{"type":"user","isMeta":false,"message":{"role":"user","content":"yo"}} +{"type":"user","isMeta":false,"message":{"role":"user","content":"yo"}} {"type":"user","message":{"role":"user","content":"[Request interrupted by user]"}} {"type":"user","isMeta":false,"message":{"role":"user","content":"turn 2 prompt"}} {"type":"assistant","requestId":"req_e","message":{"id":"msg_e","content":[],"usage":{"input_tokens":800,"cache_read_input_tokens":8000,"cache_execution_input_tokens":0,"output_tokens":40}}} diff --git a/test/scripts/turnBudgetHookScript.test.ts b/test/scripts/turnBudgetHookScript.test.ts index d51bf27e..db7bbf77 100644 --- a/test/scripts/turnBudgetHookScript.test.ts +++ b/test/scripts/turnBudgetHookScript.test.ts @@ -39,7 +39,7 @@ describe('accounting primitives (canonical core)', () => { }; expect(inputSideTokens(usage)).toBe(350); expect(billedTotalTokens(usage)).toBe(375); - expect(inputSideTokens(undefined)).toBe(0); + expect(inputSideTokens()).toBe(0); const responses = [ { type: 'user' }, @@ -79,7 +79,7 @@ describe('hook isRealUserLine (raw JSONL shapes)', () => { ).toBe(false); expect( isRealUserLine({ type: 'user', message: { content: 'yo' } }) - ).toBe(false); + ).toBe(true); expect(isRealUserLine({ type: 'assistant', message: { content: 'hi' } })).toBe(false); }); }); @@ -104,13 +104,15 @@ describe('isUserChunkLine — canonical turn-boundary predicate', () => { } }); - it('teammate relays with teammate_id never start a turn', () => { + it('complete teammate relays never start a turn; malformed wrappers stay user text (#59)', () => { expect( isUserChunkLine(userLine('yo')) ).toBe(false); + // unclosed wrapper — the strict parser cannot render it as a card either, + // so it is preserved as user input rather than swallowed expect( isUserChunkLine(userLine([{ type: 'text', text: 'hi' }])) - ).toBe(false); + ).toBe(true); }); it(' is user-initiated and DOES start a turn', () => { @@ -167,11 +169,11 @@ describe('hook readConfig', () => { }); expect(readConfig('{}').enabled).toBe(true); expect(readConfig('not json').budget).toBeGreaterThan(0); - expect(readConfig(undefined).enabled).toBe(true); + expect(readConfig().enabled).toBe(true); }); }); -describe('hook deny output shape', async () => { +describe('hook deny output shape', () => { it('emits permissionDecision deny via stdout', async () => { const { deny } = await import('../../scripts/turn-budget-hook.mjs'); const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true); diff --git a/test/shared/turnAccounting.relay.test.ts b/test/shared/turnAccounting.relay.test.ts index c2229587..50976bb7 100644 --- a/test/shared/turnAccounting.relay.test.ts +++ b/test/shared/turnAccounting.relay.test.ts @@ -27,22 +27,35 @@ describe('isTeammateRelayLine / isTranscriptTurnLine (issue #55)', () => { expect(isTeammateRelayLine(rawLine(relayString))).toBe(true); expect(isTeammateRelayLine(flatLine(relayString))).toBe(true); expect(isTeammateRelayLine(rawLine([{ type: 'text', text: relayString }]))).toBe(true); - // the fixture shape without teammate_id (old-style wrapper) is still a relay - expect(isTeammateRelayLine(rawLine("yo"))).toBe( - true - ); - // relay block next to a real question — the line is a relay (symmetric with - // the isUserChunkLine exclusion: any relay block makes the line a relay) + // two wrappers with nothing else are still one relay line expect( - isTeammateRelayLine( - rawLine([ - { type: 'text', text: relayString }, - { type: 'text', text: 'go' }, - ]) - ) + isTeammateRelayLine(rawLine(`${relayString}\nhi`)) ).toBe(true); }); + it('mixed content is a user message, not a relay (issue #59)', () => { + // the issue repro: a prompt starting with a relay wrapper plus a question + const mixedString = `${relayString}\nWhy is this tag in the log?`; + expect(isTeammateRelayLine(rawLine(mixedString))).toBe(false); + expect(isUserChunkLine(rawLine(mixedString))).toBe(true); + // turn semantics unchanged: one transcript turn either way + expect(isTranscriptTurnLine(rawLine(mixedString))).toBe(true); + // array form: a relay block next to a real question keeps the line user + const mixedArray = [ + { type: 'text', text: relayString }, + { type: 'text', text: 'go' }, + ]; + expect(isTeammateRelayLine(rawLine(mixedArray))).toBe(false); + expect(isUserChunkLine(rawLine(mixedArray))).toBe(true); + expect(isTranscriptTurnLine(rawLine(mixedArray))).toBe(true); + // a wrapper the strict parser cannot render (unclosed, old-style id='x') + // is preserved as user text, not swallowed as an invisible relay + expect(isTeammateRelayLine(rawLine('hi'))).toBe(false); + expect(isTeammateRelayLine(rawLine("yo"))).toBe( + false + ); + }); + it('meta lines, non-user lines and plain text are not relays', () => { expect(isTeammateRelayLine(rawLine(relayString, true))).toBe(false); expect(isTeammateRelayLine({ type: 'assistant', message: { content: relayString } })).toBe(