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
79 changes: 77 additions & 2 deletions packages/core/src/tools/send-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const DEFAULT_MODE = 'default' as ApprovalMode;
const PLAN_MODE = 'plan' as ApprovalMode;

function makeTeamConfig(opts?: {
registry?: BackgroundTaskRegistry;
teamManager?: {
sendMessage: (...args: unknown[]) => Promise<void>;
broadcast: (...args: unknown[]) => Promise<BroadcastResult>;
Expand All @@ -43,7 +44,8 @@ function makeTeamConfig(opts?: {
: null;
return {
getTeamManager: () => teamManager,
getBackgroundTaskRegistry: () => new BackgroundTaskRegistry(),
getBackgroundTaskRegistry: () =>
opts?.registry ?? new BackgroundTaskRegistry(),
getApprovalMode: () => opts?.approvalMode ?? DEFAULT_MODE,
} as unknown as Config;
}
Expand Down Expand Up @@ -211,6 +213,40 @@ describe('SendMessageTool — team mode', () => {
expect(() => tool.build({} as never)).toThrow();
expect(() => tool.build({ to: 'alice' } as never)).toThrow();
});

it('rejects ambiguous teammate and background-task destinations', async () => {
const registry = new BackgroundTaskRegistry();
registry.register({
agentId: 'agent-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
isBackgrounded: true,
outputFile: '/tmp/test.jsonl',
});
const sendMessage = vi.fn().mockResolvedValue(undefined);
const tool = new SendMessageTool(
makeTeamConfig({
registry,
teamManager: { sendMessage, broadcast: vi.fn() },
}),
);

const result = await tool.validateBuildAndExecute(
{
to: 'alice',
task_id: 'agent-1',
message: 'ambiguous destination',
},
new AbortController().signal,
);

expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
expect(result.llmContent).toContain('Only one of "to" or "task_id"');
expect(registry.get('agent-1')!.pendingMessages).toEqual([]);
expect(sendMessage).not.toHaveBeenCalled();
});
});

describe('SendMessageTool — background-task mode', () => {
Expand All @@ -226,7 +262,10 @@ describe('SendMessageTool — background-task mode', () => {
reviveCompletedBackgroundAgent = vi.fn();
config = {
getBackgroundTaskRegistry: () => registry,
getTeamManager: () => null,
getTeamManager: () =>
({
getTeamFile: () => ({ members: [{ name: 'qa-reviewer' }] }),
}) as ReturnType<Config['getTeamManager']>,
Comment on lines +265 to +268

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] Swapping this fixture from getTeamManager: () => null to a non-null stub removed the only test coverage of the new hint code's no-team branch (teamManager ? findMemberByName(...) : undefined in send-message.ts). After the swap, no test exercises a task_id miss while no team is active — the production-common solo-session path — so a follow-up edit that drops the null guard would ship green.

A mutation probe confirmed the gap: removing the teamManager ? guard leaves all 23 existing tests green, while a null-manager {task_id: 'nope', message} call then crashes into EXECUTION_FAILED instead of returning SEND_MESSAGE_NOT_FOUND:

AssertionError: expected 'execution_failed' to be 'send_message_not_found'

Keep the stub for the hint test, but add one not-found case with a null team manager: build a config with getTeamManager: () => null, call validateBuildAndExecute({ task_id: 'nope', message: 'hello' }, ...), and expect ToolErrorType.SEND_MESSAGE_NOT_FOUND with 'No background task found' and no hint.

中文说明

将此处 fixture 从 getTeamManager: () => null 换成非空 stub,移除了新增提示代码"无团队"分支(send-message.ts 中的 teamManager ? findMemberByName(...) : undefined)的唯一测试覆盖。替换之后,没有任何测试覆盖"无活动团队时 task_id 未命中"这一场景——而这正是生产上常见的单人会话路径——因此后续若有编辑移除空值保护,测试仍会全绿通过。

变异探针确认了该缺口:移除 teamManager ? 保护后,现有 23 个测试仍全部通过,而空团队管理器下的 {task_id: 'nope', message} 调用会崩溃为 EXECUTION_FAILED,而不是返回 SEND_MESSAGE_NOT_FOUND

AssertionError: expected 'execution_failed' to be 'send_message_not_found'

建议保留该 stub 用于提示测试,但补充一个空团队管理器的未命中用例:构造 getTeamManager: () => null 的 config,调用 validateBuildAndExecute({ task_id: 'nope', message: 'hello' }, ...),断言 ToolErrorType.SEND_MESSAGE_NOT_FOUND、包含 'No background task found' 且不含提示文本。

— qwen3.8-max via Qwen Code /review (v0.22.0)

resumeBackgroundAgent,
reviveCompletedBackgroundAgent,
} as unknown as Config;
Expand Down Expand Up @@ -319,8 +358,44 @@ describe('SendMessageTool — background-task mode', () => {
new AbortController().signal,
);

expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_FOUND);
expect(result.error?.message).toBe('Task not found: nope');
expect(result.llmContent).toContain('No background task found');
expect(result.llmContent).not.toContain('use `to:');
expect(result.returnDisplay).toContain('Task not found.');
expect(result.returnDisplay).not.toContain('use "to"');
});

it('returns error for non-existent task without an active team', async () => {
const noTeamTool = new SendMessageTool(
makeTeamConfig({ registry, teamManager: null }),
);
const result = await noTeamTool.validateBuildAndExecute(
{ task_id: 'nope', message: 'hello' },
new AbortController().signal,
);

expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_FOUND);
expect(result.llmContent).toContain('No background task found');
expect(result.llmContent).not.toContain('use `to:');
expect(result.returnDisplay).toContain('Task not found.');
expect(result.returnDisplay).not.toContain('use "to"');
});

it('suggests the teammate destination for a matching task ID', async () => {
const result = await tool.validateBuildAndExecute(
{ task_id: 'QA Reviewer', message: 'hello' },
new AbortController().signal,
);

expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_FOUND);
expect(result.error?.message).toContain(
'use `to: "qa-reviewer"` instead of `task_id`',
);
expect(result.llmContent).toContain('use `to: "qa-reviewer"`');
expect(result.returnDisplay).toContain(
'use "to" for teammate "qa-reviewer"',
);
Comment on lines +396 to +398

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] This round adds returnDisplay coverage to the positive hint test, but its two negative siblings — returns error for non-existent task and returns error for non-existent task without an active team — still assert only on llmContent/error type, leaving the no-hint returnDisplay ('Task not found.') unpinned. Production builds both surfaces from the same teammate ternary in send-message.ts; if a future refactor unconditionally formats the hint into returnDisplay (the ternary condition dropped, or the hint hoisted into a shared formatter), the UI shows a teammate hint precisely when no teammate matches or no team exists — the misleading display this PR exists to prevent — and the whole suite stays green because only the positive case inspects returnDisplay. I confirmed this with a mutation probe in a scratch tree at this commit: dropping the ternary so returnDisplay always emits the hint leaves all 24 tests green (the mutation survives); adding the assertions suggested below makes exactly the two negative tests flip red; with the mutation reverted and the assertions kept the suite is green again, so they pin real behaviour without false-failing.

Consider adding display assertions to both no-hint tests, e.g.:

expect(result.returnDisplay).toContain('Task not found.');
expect(result.returnDisplay).not.toContain('use "to"');

Fix witness: mutate the not-found branch in send-message.ts to always emit the hint-formatted returnDisplay — with the added assertions the two negative tests must go red (without them that mutation survives today).

中文说明

本轮为正向提示测试新增了 returnDisplay 断言,但它的两个负向兄弟测试(returns error for non-existent taskreturns error for non-existent task without an active team)仍然只断言 llmContent/错误类型,未对无提示时的 returnDisplay'Task not found.')形成约束。生产代码中两个界面来自 send-message.ts 里同一个 teammate 三元表达式;如果未来重构把提示无条件地写入 returnDisplay(去掉三元条件,或把提示移入共享的格式化逻辑),那么在没有匹配 teammate 或没有团队时界面也会显示 teammate 提示——这正是本 PR 要防止的误导性显示——而整个测试套件仍会全绿,因为只有正向用例检查了 returnDisplay。我在本提交的临时工作树中用变异探针确认了这一点:去掉三元表达式使 returnDisplay 始终输出提示时,24 个测试仍全部通过(变异存活);加上下方建议的断言后,恰好那两个负向测试变红;还原变异并保留断言后套件再次全绿,说明断言约束的是真实行为、不会误报。

建议在两个无提示测试中各加一条显示断言,例如:

expect(result.returnDisplay).toContain('Task not found.');
expect(result.returnDisplay).not.toContain('use "to"');

修复见证:将 send-message.ts 的 not-found 分支变异为始终输出带提示的 returnDisplay——加上断言后那两个负向测试必须变红(目前不加断言时该变异可以存活)。

— qwen3.8-max via Qwen Code /review (v0.22.2)

});

it('returns error for a failed (non-running, non-revivable) task', async () => {
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/tools/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { PermissionDecision } from '../permissions/types.js';
import { ToolErrorType } from './tool-error.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import { getAgentName } from '../agents/team/identity.js';
import { findMemberByName } from '../agents/team/teamHelpers.js';
import { LEADER_NAME } from '../agents/team/types.js';
import type { ApprovalMode } from '../config/approval-mode.js';
import {
Expand Down Expand Up @@ -224,11 +225,23 @@ class SendMessageInvocation extends BaseToolInvocation<
const entry = registry.get(this.params.task_id);

if (!entry) {
const teamManager = this.config.getTeamManager();
const teammate = teamManager
? findMemberByName(
teamManager.getTeamFile().members,
this.params.task_id,
)
: undefined;
const teammateHint = teammate
? ` Did you mean to message teammate "${teammate.name}"? If so, use \`to: "${teammate.name}"\` instead of \`task_id\`.`
: '';
return {
llmContent: `Error: No background task found with ID "${this.params.task_id}".`,
returnDisplay: 'Task not found.',
llmContent: `Error: No background task found with ID "${this.params.task_id}".${teammateHint}`,
returnDisplay: teammate
? `Task not found; use "to" for teammate "${teammate.name}".`
: 'Task not found.',
Comment on lines +240 to +242

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] This new teammate-hint branch of returnDisplay has no test assertion — the one-line mutation replacing this ternary with 'Task not found.' survives the whole suite. I confirmed this with a mutation probe in a scratch tree: baseline send-message.test.ts is 24/24 green, the mutant is 24/24 green as well, and adding the assertion below against the mutant turns it red (× suggests the teammate destination for a matching task ID → expected 'Task not found.' to contain 'use "to" for teammate "qa-reviewer"'). So if a later change or revert drops the user-facing half of the hint, every test stays green while the TUI silently loses the disambiguation guidance this PR exists to surface — the user sees a bare "Task not found." with no pointer to the to: field. With the assertion added and the PR code restored the suite is 24/24 green again.

The assertion belongs in the suggests the teammate destination for a matching task ID test in send-message.test.ts:

expect(result.returnDisplay).toContain('use "to" for teammate "qa-reviewer"');
中文说明

returnDisplay 中新增的 teammate 提示分支没有任何测试断言——把这个三元表达式直接替换为 'Task not found.' 的单行突变可以在整套测试中存活。我在独立的 scratch 工作树中做了突变探测验证:基线 send-message.test.ts 为 24/24 全绿;应用该突变后同样 24/24 全绿;对突变体加入下面的断言后变红(× suggests the teammate destination for a matching task ID → expected 'Task not found.' to contain 'use "to" for teammate "qa-reviewer"')。因此,如果后续改动(或回退)删掉了提示中面向用户的这一半,所有测试仍会是绿的,而 TUI 会悄悄丢失本 PR 要呈现的歧义消除指引——用户只会看到一个没有任何 to: 字段指引的 "Task not found."。加入下面的断言并恢复 PR 代码后,重新回到 24/24 全绿。

send-message.test.tssuggests the teammate destination for a matching task ID 测试中加入该断言:

expect(result.returnDisplay).toContain('use "to" for teammate "qa-reviewer"');

— qwen3.8-max via Qwen Code /review (v0.22.0)

error: {
message: `Task not found: ${this.params.task_id}`,
message: `Task not found: ${this.params.task_id}${teammate ? `.${teammateHint}` : ''}`,
type: ToolErrorType.SEND_MESSAGE_NOT_FOUND,
},
};
Expand Down Expand Up @@ -536,6 +549,15 @@ export class SendMessageTool extends BaseDeclarativeTool<
return new SendMessageInvocation(this.config, params);
}

protected override validateToolParamValues(
params: SendMessageParams,
): string | null {
if (params.to && params.task_id) {
return 'Only one of "to" or "task_id" may be provided.';
}
return null;
}

/**
* Forward the routing fields and the message verbatim to the classifier —
* `to`/`task_id` identify the privileged sink and the `message` itself is
Expand Down
Loading