Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c46a88a
feat(core): add Agent Team foundation (experimental, flag-gated)
tanzhenxin Jun 8, 2026
8c8bf2e
feat(core): add send_message team routing (experimental)
tanzhenxin Jun 8, 2026
cd573b9
feat(core): let the Agent tool spawn named teammates (experimental)
tanzhenxin Jun 8, 2026
336f8c8
feat(cli): render team_result/task_list tool displays (experimental)
tanzhenxin Jun 8, 2026
9e11fcc
feat(core): treat teammate messages as top-level turns (experimental)
tanzhenxin Jun 8, 2026
ac76dbb
feat(cli): drive teammates from the headless run loop (experimental)
tanzhenxin Jun 8, 2026
0954dd7
feat(cli): drive teammates from the interactive turn loop (experimental)
tanzhenxin Jun 8, 2026
3d16cd1
fix(core): declare proper-lockfile dependency for the team subsystem
tanzhenxin Jun 8, 2026
50e95be
fix(team): address review findings on the agent-team subsystem (exper…
tanzhenxin Jun 8, 2026
47649ad
fix(core): close ownership TOCTOU and lock-ordering hazard in agent-t…
tanzhenxin Jun 9, 2026
0f8d7ea
fix(core): harden agent-team message handling and auto-claim
tanzhenxin Jun 9, 2026
c8e5c51
fix(core): require approval for agent-team task_create/task_update
tanzhenxin Jun 9, 2026
818a90e
docs(core): correct completeOnIdle JSDoc for team teammates
tanzhenxin Jun 9, 2026
ba363e8
fix(core): harden agent-team leader callback and task envelope
tanzhenxin Jun 9, 2026
3d9de93
fix(core): make deleteTask edge cleanup resilient to partial failure
tanzhenxin Jun 9, 2026
78f0013
fix(cli): mount useTeamInProcess so teammate tabs render
tanzhenxin Jun 9, 2026
9219502
test(terminal-capture): add agent-team feature demo + capture fixes
tanzhenxin Jun 9, 2026
50a868e
fix(core): serialize in-process mailbox writers to fix Windows lock f…
tanzhenxin Jun 9, 2026
8e3be23
feat(team): render teammate reports as a compact notification line
tanzhenxin Jun 9, 2026
61e8a94
fix(terminal-capture): widen agent-team-demo Phase C budget so the GI…
tanzhenxin Jun 9, 2026
5a84641
fix(core): separate task-content nonce; forward send_message summary
tanzhenxin Jun 9, 2026
2ddb5a2
fix(core,cli): harden agent-team messaging per review round 4
tanzhenxin Jun 10, 2026
5a0d5e9
Merge remote-tracking branch 'origin/main' into feat/agent-team-v2
tanzhenxin Jun 10, 2026
41f873d
fix(core): don't let a failed dependent unblock abort task completion
tanzhenxin Jun 10, 2026
a5d2532
Merge remote-tracking branch 'origin/main' into feat/agent-team-v2
tanzhenxin Jun 10, 2026
583baca
fix(core): quarantine corrupt teammate inboxes; skip task scan when n…
tanzhenxin Jun 10, 2026
74cc344
fix(core): re-check task ownership under the lock when unassigning a …
tanzhenxin Jun 10, 2026
b5d6018
fix(core): harden team task file layer against partial writes and tra…
tanzhenxin Jun 10, 2026
58929fb
fix(core): drain messages enqueued during the IDLE transition; settle…
tanzhenxin Jun 10, 2026
9337cae
test(core): align FakeAgent queue and abort semantics with AgentInter…
tanzhenxin Jun 10, 2026
25ec5af
fix(core): surface spawn failures, handle shutdown_rejected, envelope…
tanzhenxin Jun 10, 2026
aacb825
fix(core): exclude workflow tool from teammates
tanzhenxin Jun 10, 2026
afcca7e
fix(core): make task tools visible to permission review; reject depen…
tanzhenxin Jun 10, 2026
da713de
fix(core): reclaim stale teams on team_create instead of wedging the …
tanzhenxin Jun 10, 2026
a1da6c4
fix(cli): pass teammate envelopes straight to the model, skipping she…
tanzhenxin Jun 10, 2026
00f44c8
fix(core): exclude Teammate from UserPromptSubmit hooks and record it…
tanzhenxin Jun 10, 2026
f3d32a9
fix(cli): stop teammate-approval rejections from escaping as unhandle…
tanzhenxin Jun 10, 2026
2e96d18
Merge remote-tracking branch 'origin/main' into feat/agent-team-v2
tanzhenxin Jun 10, 2026
96410d1
test(core): add getSessionId to team-lifecycle mock config
tanzhenxin Jun 10, 2026
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
Prev Previous commit
Next Next commit
feat(team): render teammate reports as a compact notification line
A teammate's report was injected into the leader's conversation as a
raw <teammate_message_<nonce>> envelope and rendered verbatim as a user
bubble — a large, scaffolding-heavy block on screen for what is often
the biggest payload in the feature.

Adopt the two-text split the notification queue already uses: the full
nonce-tagged envelope still goes to the leader's model, but the user now
sees a compact "● <name> reported back" line in its place. The verbatim
USER bubble is suppressed for SendMessageType.Teammate exactly as it is
for Cron, and coordination-error notices get the same treatment.

The leader callback now delivers both the model text and a display
string built in TeamManager (where the structured sender/summary live),
so the UI never parses the envelope. Headless is unchanged — it ignores
the extra arg.
  • Loading branch information
tanzhenxin committed Jun 9, 2026
commit 8e3be23d5a949c1b3e2fcaebd5701cf12c0a21bf
54 changes: 54 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,60 @@ describe('useGeminiStream', () => {
});
});

it('renders teammate reports as a compact notification, not a raw envelope bubble', async () => {
const mockManager = { setLeaderMessageCallback: vi.fn() };
(mockConfig.getTeamManager as unknown as Mock).mockReturnValue(mockManager);

const { mockSendMessageStream } = renderTestHook();

await waitFor(() => {
expect(mockManager.setLeaderMessageCallback).toHaveBeenCalledWith(
expect.any(Function),
);
});

const display = '**scout-cli** reported back';
const modelText =
'<teammate_message_abcdef0123456789 from="scout-cli">\n' +
'a very long report that should never reach the UI verbatim\n' +
'</teammate_message_abcdef0123456789>';

const callback = (mockManager.setLeaderMessageCallback as Mock).mock
.calls[0][0] as (modelText: string, display: string) => void;

act(() => {
callback(modelText, display);
});

// The compact display line is added to history…
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'notification', text: display },
expect.any(Number),
);
});

// …and the full envelope is sent to the model as a Teammate turn.
await waitFor(() => {
expect(mockSendMessageStream).toHaveBeenCalledWith(
modelText,
expect.any(AbortSignal),
expect.any(String),
expect.objectContaining({
type: SendMessageType.Teammate,
notificationDisplayText: display,
}),
);
});

// The raw envelope is never rendered as a history item (no `> …`
// user bubble dumping the whole report on screen).
const addedTexts = (mockAddItem as Mock).mock.calls
.map((c) => (c[0] as { text?: string })?.text)
.filter((t): t is string => typeof t === 'string');
expect(addedTexts.some((t) => t.includes('teammate_message'))).toBe(false);
});

it('should not submit tool responses if not all tool calls are completed', () => {
const toolCalls: TrackedToolCall[] = [
{
Expand Down
49 changes: 38 additions & 11 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,10 +796,15 @@ export const useGeminiStream = (

localQueryToSendToGemini = trimmedQuery;

// Cron prompts are already rendered as a `● Cron: …` notification by
// the queue drain, so skip the user-message history item to avoid
// a duplicate `> …` line. Preprocessing (@/slash/shell) still runs.
if (submitType !== SendMessageType.Cron) {
// Cron prompts and teammate reports are already rendered as a
// `● …` notification by their queue drains, so skip the
// user-message history item to avoid a duplicate `> …` line (and,
// for teammates, dumping the whole nonce-tagged envelope on screen).
// Preprocessing (@/slash/shell) still runs.
if (
submitType !== SendMessageType.Cron &&
submitType !== SendMessageType.Teammate
Comment thread
tanzhenxin marked this conversation as resolved.
Outdated
) {
const insertedId = addItem(
{
type: MessageType.USER,
Expand Down Expand Up @@ -2628,7 +2633,14 @@ export const useGeminiStream = (
}, [streamingState, submitQuery, notificationTrigger, addItem]);

// ─── Teammate message integration ─────────────────────────
const teammateQueueRef = useRef<string[]>([]);
// Each entry carries the full nonce-tagged envelope (`modelText`,
// sent to the leader's model) and a compact `display` line (shown
// to the user in its place) — the same two-text split the unified
// notification queue uses, so teammate reports no longer dump the
// whole raw envelope into the conversation as a user bubble.
const teammateQueueRef = useRef<
Array<{ modelText: string; display: string }>
>([]);
const [teammateTrigger, setTeammateTrigger] = useState(0);

// Subscribe to TeamManager's leader message callback.
Expand All @@ -2654,10 +2666,12 @@ export const useGeminiStream = (
}
boundManager = manager;
if (manager) {
manager.setLeaderMessageCallback((formatted: string) => {
teammateQueueRef.current.push(formatted);
setTeammateTrigger((n) => n + 1);
});
manager.setLeaderMessageCallback(
(modelText: string, display: string) => {
teammateQueueRef.current.push({ modelText, display });
setTeammateTrigger((n) => n + 1);
},
);
}
};

Expand Down Expand Up @@ -2691,9 +2705,22 @@ export const useGeminiStream = (
teammateQueueRef.current.length > 0
) {
const batch = teammateQueueRef.current.splice(0);
submitQuery(batch.join('\n\n'), SendMessageType.Teammate);
// Render one compact `● …` line per teammate report; the full
// envelope goes only to the model (the USER bubble is suppressed
// for SendMessageType.Teammate in prepareQueryForGemini).
for (const entry of batch) {
addItem(
{ type: 'notification' as const, text: entry.display },
Date.now(),
);
}
const modelText = batch.map((e) => e.modelText).join('\n\n');
const display = batch.map((e) => e.display).join('; ');
submitQuery(modelText, SendMessageType.Teammate, undefined, {
notificationDisplayText: display,
});
}
}, [streamingState, submitQuery, teammateTrigger]);
}, [streamingState, submitQuery, teammateTrigger, addItem]);

return {
streamingState,
Expand Down
54 changes: 48 additions & 6 deletions packages/core/src/agents/team/TeamManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,16 @@ export class TeamManager {
/** Leader inbox polling interval. */
private pollingInterval: ReturnType<typeof setInterval> | null = null;

/** Callback to inject teammate messages into the leader. */
private leaderMessageCallback: ((message: string) => void) | null = null;
/**
* Callback to inject teammate messages into the leader. Receives the
* full model-bound text (the nonce-tagged envelope) and a compact,
* human-readable `display` line for the leader's UI — the two-text
* split that lets the on-screen line stay short while the model still
* gets the whole report.
*/
private leaderMessageCallback:
| ((message: string, display: string) => void)
| null = null;

/** Tracks how far we've read in the leader inbox. */
private lastInboxOffset = 0;
Expand Down Expand Up @@ -650,7 +658,9 @@ export class TeamManager {
* to the leader's conversation. Called by the CLI layer.
* Pass `null` to detach a previously-installed callback.
*/
setLeaderMessageCallback(cb: ((message: string) => void) | null): void {
setLeaderMessageCallback(
cb: ((message: string, display: string) => void) | null,
): void {
this.leaderMessageCallback = cb;
}

Expand Down Expand Up @@ -725,7 +735,10 @@ export class TeamManager {
// is being torn down or replaced.
const callback = this.leaderMessageCallback;
if (callback) {
callback(this.formatLeaderEnvelope(newMessages).join('\n\n'));
callback(
this.formatLeaderEnvelope(newMessages).join('\n\n'),
this.formatLeaderDisplay(newMessages),
);
}
}

Expand All @@ -750,6 +763,34 @@ export class TeamManager {
);
}

/**
* Build a compact, one-line summary of a batch of teammate→leader
* messages for the leader's UI. The full `formatLeaderEnvelope` text
* still goes to the model; this is the short line the user sees in
* its place (rendered as a `●` notification), so the conversation
* isn't flooded with the entire raw report.
*
* Uses each message's `summary` when the teammate provided one, else
* a "{name} reported back" fallback. Names are wrapped in `**` so the
* UI's inline-markdown renderer bolds them. Kept separate from
* `formatLeaderEnvelope` so the model payload and the on-screen line
* can diverge.
*/
formatLeaderDisplay(
messages: ReadonlyArray<{ from: string; summary?: string }>,
): string {
const first = messages[0];
if (messages.length === 1 && first) {
return first.summary
? `**${first.from}**: ${first.summary}`
: `**${first.from}** reported back`;
}
const names = [...new Set(messages.map((m) => m.from))];
return names.length > 0
? `**${names.join('**, **')}** reported back`
: 'Teammate reported back';
}

/**
* Returns true if any teammate is still actively working or
* has pending messages/tasks to process. An IDLE teammate
Expand Down Expand Up @@ -844,15 +885,15 @@ export class TeamManager {

// Resolve when a message is delivered.
const origCb = this.leaderMessageCallback;
const wrappedCallback = (msg: string) => {
const wrappedCallback = (msg: string, display: string) => {
// Restore early so a second message doesn't re-enter the
// wrapper after we've already finished. Same identity-
// check as in finish() — don't stomp on an externally-set
// callback.
if (this.leaderMessageCallback === wrappedCallback) {
this.leaderMessageCallback = origCb;
}
origCb?.(msg);
origCb?.(msg, display);
finish('message');
};
this.leaderMessageCallback = wrappedCallback;
Expand Down Expand Up @@ -1049,6 +1090,7 @@ export class TeamManager {
try {
this.leaderMessageCallback?.(
`<team_error>Coordination step "${label}" failed: ${msg}</team_error>`,
`Team coordination step "${label}" failed`,
);
} catch (cbErr) {
const cbMsg = cbErr instanceof Error ? cbErr.message : String(cbErr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,5 +461,52 @@ describe('TeamCoordinationHarness', () => {
// inside the envelope (not interpreted as a closing tag).
expect(formatted).toContain(spoof);
});

it('delivers a compact display line alongside the full envelope', async () => {
const h = await createHarness();
await h.spawnTeammate('worker');

const captured: Array<{ modelText: string; display: string }> = [];
h.teamManager.setLeaderMessageCallback((modelText, display) =>
captured.push({ modelText, display }),
);

const report = 'a very long report '.repeat(50);
await h.teamManager.sendMessage('leader', report, 'worker');
await h.teamManager.drainLeaderInbox();

expect(captured).toHaveLength(1);
const { modelText, display } = captured[0]!;
// The model still receives the full nonce-tagged envelope + body.
expect(modelText).toMatch(
/^<teammate_message_[a-f0-9]{16} from="worker"/,
);
expect(modelText).toContain('a very long report');
// The UI display line is compact: names the sender only — no
// envelope scaffolding, no report body.
expect(display).toBe('**worker** reported back');
expect(display).not.toContain('teammate_message');
expect(display).not.toContain('a very long report');
});

it('formatLeaderDisplay summarizes one, many, and summarized batches', async () => {
const h = await createHarness();
const fmt = (msgs: Array<{ from: string; summary?: string }>) =>
h.teamManager.formatLeaderDisplay(msgs);

expect(fmt([{ from: 'scout' }])).toBe('**scout** reported back');
// A teammate-provided summary is surfaced verbatim.
expect(fmt([{ from: 'scout', summary: 'core pkg done' }])).toBe(
'**scout**: core pkg done',
);
// Multiple distinct senders are listed.
expect(fmt([{ from: 'a' }, { from: 'b' }])).toBe(
'**a**, **b** reported back',
);
// Duplicate senders collapse to one name.
expect(fmt([{ from: 'a' }, { from: 'a' }])).toBe('**a** reported back');
// Defensive fallback for an empty batch.
expect(fmt([])).toBe('Teammate reported back');
});
});
});
Loading