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
8 changes: 8 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
let lastSessionMock:
| {
captureHistorySnapshot: ReturnType<typeof vi.fn>;
emitGoalStatus: ReturnType<typeof vi.fn>;
restoreHistory: ReturnType<typeof vi.fn>;
rewindToTurn: ReturnType<typeof vi.fn>;
}
Expand Down Expand Up @@ -1318,6 +1319,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
installRewriter: vi.fn(),
startCronScheduler: vi.fn(),
dispose: vi.fn(),
emitGoalStatus: vi.fn(),
captureHistorySnapshot: vi
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]),
Expand Down Expand Up @@ -2299,6 +2301,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
}),
).resolves.toEqual({ cleared: true, condition: 'ship it' });
expect(unregisterGoalHook).toHaveBeenCalledWith(innerConfig, sessionId);
expect(lastSessionMock?.emitGoalStatus).toHaveBeenCalledWith({
kind: 'cleared',
condition: 'ship it',
iterations: 1,
durationMs: expect.any(Number),
});

mockConnectionState.resolve();
await agentPromise;
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5799,6 +5799,14 @@ class QwenAgent implements Agent {
const session = this.sessionOrThrow(sessionId);
const config = session.getConfig();
const cleared = unregisterGoalHook(config, sessionId);
if (cleared) {
session.emitGoalStatus({
kind: 'cleared',
condition: cleared.condition,
iterations: cleared.iterations,
durationMs: Date.now() - cleared.setAt,
});
}
debugLogger.info(
`sessionGoalClear sessionId=${sessionId} cleared=${!!cleared} condition=${cleared?.condition ?? '(none)'}`,
);
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import {
} from '../../nonInteractiveCliCommands.js';
import { isSlashCommand } from '../../ui/utils/commandUtils.js';
import { CommandKind } from '../../ui/commands/types.js';
import { MessageType, type HistoryItemGoalStatus } from '../../ui/types.js';
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
import { classifyApiError } from '../../ui/hooks/useGeminiStream.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
Expand Down Expand Up @@ -589,6 +590,14 @@ export class Session implements SessionContext {
});
}

emitGoalStatus(status: Omit<HistoryItemGoalStatus, 'id' | 'type'>): void {
void this.messageEmitter.emitGoalStatus(status).catch((error) => {
debugLogger.warn(
`Failed to emit goal status update: ${this.#formatError(error)}`,
);
});
}

/**
* Replays conversation history to the client using modular components.
* Delegates to HistoryReplayer for consistent event emission.
Expand Down Expand Up @@ -3701,6 +3710,30 @@ export class Session implements SessionContext {
}
}

#emitGoalStatusItems(result: NonInteractiveSlashCommandResult): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] #emitGoalStatusItems — the glue between slash command outputHistoryItems and session.emitGoalStatus — is only tested indirectly. The /goal clear path is covered via acpAgent.test.ts (which calls emitGoalStatus directly), and outputHistoryItems population is covered in nonInteractiveCliCommands.test.ts. But the full /goal set chain (handleSlashCommand → goalCommand → outputHistoryItems → #emitGoalStatusItems → emitGoalStatus → MessageEmitter) is never verified end-to-end.

A bug in the field-mapping logic (e.g., a missing conditional spread for setAt, durationMs, or lastReason) would silently produce incomplete transcript data, and getLatestActiveGoalFromBlocks would reconstruct an incorrect activeGoal after page refresh.

— qwen3.7-max via Qwen Code /review

if (!('outputHistoryItems' in result)) {
return;
}
for (const item of result.outputHistoryItems ?? []) {
if (item.type === MessageType.GOAL_STATUS) {
this.emitGoalStatus({
kind: item.kind,
condition: item.condition,
...(item.iterations !== undefined
? { iterations: item.iterations }
: {}),
...(item.setAt !== undefined ? { setAt: item.setAt } : {}),
...(item.durationMs !== undefined
? { durationMs: item.durationMs }
: {}),
...(item.lastReason !== undefined
? { lastReason: item.lastReason }
: {}),
});
}
}
}

/**
* Processes the result of a slash command execution.
*
Expand All @@ -3721,6 +3754,8 @@ export class Session implements SessionContext {
result: NonInteractiveSlashCommandResult,
originalPrompt: ContentBlock[],
): Promise<Part[] | null> {
this.#emitGoalStatusItems(result);

switch (result.type) {
case 'submit_prompt':
// Command wants to submit a prompt to the model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ describe('MessageEmitter', () => {
});
});

describe('emitGoalStatus', () => {
it('should send a goal status update in metadata', async () => {
const status = {
kind: 'set' as const,
condition: 'ship goal support',
setAt: 1234,
};

await emitter.emitGoalStatus(status);

expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
goalStatus: status,
},
});
});
});

describe('emitAgentThought', () => {
it('should send agent_thought_chunk update with text content', async () => {
await emitter.emitAgentThought('Let me think about this...');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type GoalTerminalEvent,
} from '@qwen-code/qwen-code-core';
import { BaseEmitter } from './BaseEmitter.js';
import type { HistoryItemGoalStatus } from '../../../ui/types.js';

/**
* Handles emission of text message chunks (user, agent, thought).
Expand Down Expand Up @@ -68,6 +69,18 @@ export class MessageEmitter extends BaseEmitter {
});
}

async emitGoalStatus(
status: Omit<HistoryItemGoalStatus, 'id' | 'type'>,
): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
goalStatus: status,
},
});
}

/**
* Emits a user message chunk.
*
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/nonInteractiveCliCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@ describe('handleSlashCommand', () => {
text: expect.stringContaining('write a hello world script'),
}),
]);
expect(result.outputHistoryItems).toEqual([
expect.objectContaining({
type: 'goal_status',
kind: 'set',
condition: 'write a hello world script',
setAt: expect.any(Number),
}),
]);
}
});

Expand Down Expand Up @@ -304,6 +312,16 @@ describe('handleSlashCommand', () => {
messageType: 'info',
content: 'Goal cleared: write a hello world script',
});
if (result.type === 'message') {
expect(result.outputHistoryItems).toEqual([
expect.objectContaining({
type: 'goal_status',
kind: 'cleared',
condition: 'write a hello world script',
durationMs: expect.any(Number),
}),
]);
}
});

it('should report cleared goal for ACP /goal clear', async () => {
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/nonInteractiveCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type ExecutionMode,
} from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { HistoryItemWithoutId } from './ui/types.js';
import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
import { t } from './i18n/index.js';
Expand All @@ -50,11 +51,13 @@ export type NonInteractiveSlashCommandResult =
| {
type: 'submit_prompt';
content: PartListUnion;
outputHistoryItems?: HistoryItemWithoutId[];
}
| {
type: 'message';
messageType: 'info' | 'warning' | 'error';
content: string;
outputHistoryItems?: HistoryItemWithoutId[];
}
| {
type: 'stream_messages';
Expand Down Expand Up @@ -88,19 +91,22 @@ export type NonInteractiveSlashCommandResult =
*/
function handleCommandResult(
result: SlashCommandActionReturn,
outputHistoryItems?: HistoryItemWithoutId[],
): NonInteractiveSlashCommandResult {
switch (result.type) {
case 'submit_prompt':
return {
type: 'submit_prompt',
content: result.content,
...(outputHistoryItems?.length ? { outputHistoryItems } : {}),
};

case 'message':
return {
type: 'message',
messageType: result.messageType,
content: result.content,
...(outputHistoryItems?.length ? { outputHistoryItems } : {}),
};

case 'stream_messages':
Expand Down Expand Up @@ -399,14 +405,21 @@ export const handleSlashCommand = async (

const logger = new Logger(config?.getSessionId() || '', config?.storage);

const outputHistoryItems: HistoryItemWithoutId[] = [];
const ui = createNonInteractiveUI();
ui.addItem = (item) => {
outputHistoryItems.push(item);
return 0;
};

const context: CommandContext = {
executionMode,
services: {
config,
settings,
logger,
},
ui: createNonInteractiveUI(),
ui,
session: {
stats: sessionStats,
sessionShellAllowlist: new Set(),
Expand Down Expand Up @@ -440,11 +453,14 @@ export const handleSlashCommand = async (
if (hookResult.blockedResult) {
return hookResult.blockedResult;
}
return handleCommandResult({ ...result, content: hookResult.content });
return handleCommandResult(
{ ...result, content: hookResult.content },
outputHistoryItems,
);
}

// Handle different result types
return handleCommandResult(result);
return handleCommandResult(result, outputHistoryItems);
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/goalCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export const goalCommand: SlashCommand = {
type: MessageType.GOAL_STATUS,
kind: 'set',
condition: registered.condition,
setAt: registered.setAt,
};
context.ui.addItem(setItem, Date.now());

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,8 +562,9 @@ export type HistoryItemGoalStatus = HistoryItemBase & {
type: 'goal_status';
kind: GoalStatusKind;
condition: string;
/** Set for progress and terminal goal states. */
/** Set for active, progress, and terminal goal states. */
iterations?: number;
setAt?: number;
durationMs?: number;
lastReason?: string;
};
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,13 @@ function appendStatusBlock(
...(event?.type === 'error' && event.source
? { source: event.source }
: {}),
...((event?.type === 'status' || event?.type === 'debug') && event.source
? { source: event.source }
: {}),
...((event?.type === 'status' || event?.type === 'debug') &&
event.data !== undefined
? { data: event.data }
: {}),
};
appendBlock(state, block);
if (opts.clearActiveText !== false) clearActiveText(state);
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ export interface DaemonUiModelChangedEvent extends DaemonUiEventBase {
export interface DaemonUiStatusEvent extends DaemonUiEventBase {
type: 'status' | 'debug';
text: string;
source?: string;
data?: unknown;
}

export interface DaemonUiErrorEvent extends DaemonUiEventBase {
Expand Down Expand Up @@ -744,7 +746,8 @@ export interface DaemonStatusTranscriptBlock extends DaemonTranscriptBlockBase {
text: string;
code?: string;
promptId?: string;
source?: 'turn_error';
source?: string;
data?: unknown;
}

export interface DaemonPromptCancelledTranscriptBlock
Expand Down
Loading
Loading