Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ export default defineConfig([
'lastAssistantTotalTokens',
'readConfig',
'SYSTEM_OUTPUT_TAGS',
'TEAMMATE_BLOCK_RE',
],
},
],
Expand Down
12 changes: 10 additions & 2 deletions scripts/turn-accounting.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <teammate-message> wrapper
* — transcript turn input but NOT a hook-turn boundary (issue #55). */
/** Complete <teammate-message ...>...</teammate-message> 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
* <teammate-message> 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. */
Expand Down
36 changes: 27 additions & 9 deletions scripts/turn-accounting.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,24 @@ export function lastAssistantTotalTokens(responses) {
return 0;
}

/** Simplified teammate-message wrapper detection. */
/** Complete <teammate-message ...>...</teammate-message> 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 =
/<teammate-message\s+teammate_id="([^"]+)"([^>]*)>([\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('<teammate-message');
const trimmed = t.trim();
return trimmed !== '' && stripTeammateBlocks(trimmed).trim() === '';
}

/** System-output wrapper tags — a user line starting with one of these is
Expand Down Expand Up @@ -125,12 +140,13 @@ export function isUserChunkLine(m) {
}

/**
* Teammate relay: a non-meta user line whose content is/contains a
* <teammate-message ...> 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 <teammate-message ...> 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;
Expand All @@ -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'));
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/utils/displayItemBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/shared/turnAccounting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export {
isUserChunkLine,
lastAssistantTotalTokens,
SYSTEM_OUTPUT_TAGS,
TEAMMATE_BLOCK_RE,
} from '../../scripts/turn-accounting.mjs';
8 changes: 5 additions & 3 deletions src/shared/utils/teammateMessageParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,10 +18,10 @@ export interface ParsedTeammateContent {
/**
* Regex to match a single <teammate-message> 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 =
/<teammate-message\s+teammate_id="([^"]+)"([^>]*)>([\s\S]*?)<\/teammate-message>/g;

const COLOR_RE = /color="([^"]*)"/;
const SUMMARY_RE = /summary="([^"]*)"/;

Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/turn-accounting/session.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{"type":"user","isMeta":true,"message":{"role":"user","content":[{"type":"tool_result","content":"x"}]}}
{"type":"user","message":{"role":"user","content":"<local-command-stdout>ok</local-command-stdout>"}}
{"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":"<teammate-message id='x'>yo</teammate-message>"}}
{"type":"user","isMeta":false,"message":{"role":"user","content":"<teammate-message teammate_id=\"a\">yo</teammate-message>"}}
{"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}}}
Expand Down
14 changes: 8 additions & 6 deletions test/scripts/turnBudgetHookScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -79,7 +79,7 @@ describe('hook isRealUserLine (raw JSONL shapes)', () => {
).toBe(false);
expect(
isRealUserLine({ type: 'user', message: { content: '<teammate-message id="x">yo</...>' } })
).toBe(false);
).toBe(true);
expect(isRealUserLine({ type: 'assistant', message: { content: 'hi' } })).toBe(false);
});
});
Expand All @@ -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('<teammate-message teammate_id="a">yo</teammate-message>'))
).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: '<teammate-message teammate_id="a">hi' }]))
).toBe(false);
).toBe(true);
});

it('<command-name> is user-initiated and DOES start a turn', () => {
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 25 additions & 12 deletions test/shared/turnAccounting.relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<teammate-message id='x'>yo</teammate-message>"))).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}\n<teammate-message teammate_id="b">hi</teammate-message>`))
).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('<teammate-message teammate_id="a">hi'))).toBe(false);
expect(isTeammateRelayLine(rawLine("<teammate-message id='x'>yo</teammate-message>"))).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(
Expand Down
Loading