fix(cli): preserve custom tool-progress payloads and honor final-only for goal summaries

- `tool_progress` JSON now carries `custom_kind`/`custom_data`, so stream-json
  clients keep structured `kind: "custom"` payloads (e.g. the MCP OAuth
  authorization URL) instead of an opaque `{"kind":"custom"}`.
- The headless `/goal` summary is suppressed on stdout under
  `--final-message-only`, matching the single-final-assistant-message contract
  (the goal status is still conveyed by the exit code).
This commit is contained in:
Kaiyi 2026-06-29 19:36:21 +08:00
parent 46dc04d982
commit ebcaa405d6
3 changed files with 62 additions and 1 deletions

View file

@ -287,8 +287,13 @@ async function runHeadlessGoal(
} finally {
unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
// The goal status is conveyed by the exit code; `--final-message-only` keeps
// stdout to the single final assistant message, so the JSON summary is
// suppressed in that mode (the text summary stays on stderr).
if (outputFormat === 'stream-json') {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
if (!finalOnly) {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
}
} else {
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
}
@ -1284,6 +1289,10 @@ function toolProgressMessage(event: Extract<Event, { type: 'tool.progress' }>):
};
if (event.update.text !== undefined) message['text'] = event.update.text;
if (event.update.percent !== undefined) message['percent'] = event.update.percent;
// `kind: 'custom'` updates (e.g. the MCP OAuth authorization URL) carry their
// payload here; keep it so stream-json clients get the structured signal.
if (event.update.customKind !== undefined) message['custom_kind'] = event.update.customKind;
if (event.update.customData !== undefined) message['custom_data'] = event.update.customData;
return message;
}

View file

@ -189,6 +189,27 @@ describe('runPrompt headless goal mode', () => {
expect(stdout.text()).toContain('"status":"complete"');
});
it('suppresses the JSON goal summary in final-message-only mode', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'shipped' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json', finalMessageOnly: true }), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('{"role":"assistant","content":"shipped"}\n');
expect(stdout.text()).not.toContain('goal.summary');
});
it('sets a distinct exit code for a non-complete final status', async () => {
mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'blocked' }) } as never);
const stdout = writer();

View file

@ -1523,6 +1523,37 @@ describe('runPrompt', () => {
expect(stderr.text()).toBe('');
});
it('preserves custom tool.progress payloads (e.g. MCP OAuth URL) in stream-json', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 75, origin: { kind: 'user' } }));
handler(
mocks.mainEvent({
type: 'tool.progress',
turnId: 75,
toolCallId: 'tc_1',
update: {
kind: 'custom',
customKind: 'mcp.oauth.authorization_url',
customData: { serverName: 'fs', authorizationUrl: 'https://auth.example/x' },
},
}),
);
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 75, reason: 'completed' }));
}
});
const stdout = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', {
stdout,
stderr: writer(),
});
expect(stdout.text()).toContain(
'{"type":"tool_progress","tool_call_id":"tc_1","kind":"custom","custom_kind":"mcp.oauth.authorization_url","custom_data":{"serverName":"fs","authorizationUrl":"https://auth.example/x"}}',
);
});
it('keeps tool.progress on stderr (not JSON) in text mode', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {