Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1979183
fix(core): pre-validate bridged tool_call arguments against the targe…
yiliang114 Sep 28, 2026
9d6a39d
fix(core): exempt media-policy targets from the bridge argument pre-c…
yiliang114 Sep 28, 2026
2c91d42
fix(core): order the bridge argument pre-check behind truncation and …
yiliang114 Sep 28, 2026
191879b
style(core): apply prettier formatting flagged by the lint gate
yiliang114 Sep 28, 2026
4c7ebf9
fix(core): close R3 review gaps in bridge pre-check policy, retry acc…
yiliang114 Sep 29, 2026
bad821c
fix(core): clone the pre-check schema and log the bridge policy looku…
yiliang114 Sep 29, 2026
888727d
test(core): use index-signature access for the mutated schema property
yiliang114 Sep 29, 2026
55783c4
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Sep 29, 2026
0fd2661
fix(core): report an in-flight subagent refresh rejection under its o…
yiliang114 Sep 29, 2026
4c101ec
fix(core): treat a target's direct and bridge retry channels as one f…
yiliang114 Sep 29, 2026
2059aea
fix(cli): let ACP's own L1 gate own a policy-denied bridge target
yiliang114 Sep 29, 2026
29d3080
fix(core): preserve target validation and contain bridge policy errors
yiliang114 Sep 29, 2026
6c9d701
test(cli): complete the pm stub in the ACP target-policy bridge tests
yiliang114 Sep 29, 2026
0ab0c14
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Sep 29, 2026
a674e3a
fix(core): isolate deferred argument validation
yiliang114 Sep 30, 2026
bacca2b
fix(core): relax nested additionalProperties in the bridge argument p…
yiliang114 Sep 30, 2026
21c07c5
fix(core): preserve composition branches in the bridge argument relax…
yiliang114 Sep 30, 2026
f92adcf
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 1, 2026
df8c325
fix(core): close the additionalProperties relaxation at the $ref boun…
yiliang114 Oct 1, 2026
bd31eac
fix(core): preserve bridge policy denials and media defaults
yiliang114 Oct 1, 2026
28a787d
Merge remote-tracking branch 'origin/main' into fix/issue-12889-bridg…
yiliang114 Oct 1, 2026
38fb20a
refactor(core): label the target's own validation error instead of pr…
yiliang114 Oct 1, 2026
403da27
fix(core): let a Responses model fill tool_call arguments; declare an…
yiliang114 Oct 1, 2026
d7465d1
test(core): use non-empty arguments that still fail validation
yiliang114 Oct 1, 2026
8b1b247
fix(core): keep deferred tool recovery in the primary session
yiliang114 Oct 1, 2026
6eb25b2
Merge remote-tracking branch 'origin/main' into codex/token-governanc…
yiliang114 Oct 1, 2026
9af1dee
fix(core): Preserve eager tool demotion during bridge recovery
yiliang114 Oct 2, 2026
d179954
Merge commit 'de2612434a3800735dcabef48bc67efcbb0077b4' into codex/to…
yiliang114 Oct 2, 2026
20930cd
fix(core): guard bridged empty-arg recovery against cancellation and …
yiliang114 Oct 2, 2026
b4f63dd
fix(acp): Keep truncated bridged calls hidden
yiliang114 Oct 2, 2026
a69f8d4
revert(core): drop the empty-argument auto-reveal fallback from the b…
yiliang114 Oct 2, 2026
af4d01f
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 2, 2026
15aac03
test(acp): Verify deferred-tool validation diagnostics
yiliang114 Oct 2, 2026
8bbced9
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 2, 2026
7682e70
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 3, 2026
28af8e4
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 3, 2026
67ea7b6
test(core): preserve shared bridged validation retry budget
yiliang114 Oct 3, 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
223 changes: 123 additions & 100 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18652,109 +18652,132 @@ describe('Session', () => {
},
);

it('routes tool_call through a hidden deferred tool in ACP', async () => {
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
const execute = vi.fn().mockResolvedValue({
llmContent: 'created issue',
returnDisplay: 'created issue',
});
const bridge = {
name: core.ToolNames.TOOL_CALL,
kind: core.Kind.Other,
description: 'Deferred tool bridge',
build: vi.fn((params: Record<string, unknown>) => ({ params })),
};
// The bridge needs both halves registered: resolution rejects a
// hidden target when tool_search is unregistered (R1-5 guard).
const toolSearch = {
name: core.ToolNames.TOOL_SEARCH,
kind: core.Kind.Other,
description: 'Deferred tool discovery',
build: vi.fn((params: Record<string, unknown>) => ({ params })),
};
const target = {
name: 'mcp__github__create_issue',
kind: core.Kind.Other,
displayName: 'CreateIssue',
description: 'Creates an issue',
canUpdateOutput: false,
isOutputMarkdown: false,
build: vi.fn().mockImplementation((params) => ({
params,
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('create issue'),
toolLocations: vi.fn().mockReturnValue([]),
execute,
})),
};
mockToolRegistry.getTool.mockImplementation((name: string) =>
name === bridge.name
? bridge
: name === target.name
? target
: name === toolSearch.name
? toolSearch
: undefined,
);
mockToolRegistry.ensureTool.mockImplementation(async (name: string) =>
name === bridge.name
? bridge
: name === target.name
? target
: name === toolSearch.name
? toolSearch
: undefined,
);
mockToolRegistry.isDeferredAndHidden.mockImplementation(
(name: string) => name === target.name,
);
const toolLoopState = {
totalToolCalls: 0,
invalidToolParamErrors: new Map<string, number>(),
toolCallKeyCounts: new Map<string, number>(),
maxToolCallKeyRepeat: 0,
loopDetected: false,
};

const result = await (
session as unknown as {
runToolCalls: (
abortSignal: AbortSignal,
promptId: string,
calls: FunctionCall[],
loopState: typeof toolLoopState,
) => Promise<{ parts: Part[] }>;
it.each(['success', 'build error', 'execute error'] as const)(
'routes tool_call through a hidden deferred tool in ACP: %s',
async (outcome) => {
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const execute = vi.fn().mockResolvedValue({
llmContent: 'created issue',
returnDisplay: 'created issue',
});
const bridge = {
name: core.ToolNames.TOOL_CALL,
kind: core.Kind.Other,
description: 'Deferred tool bridge',
build: vi.fn((params: Record<string, unknown>) => ({ params })),
};
// The bridge needs both halves registered: resolution rejects a
// hidden target when tool_search is unregistered (R1-5 guard).
const toolSearch = {
name: core.ToolNames.TOOL_SEARCH,
kind: core.Kind.Other,
description: 'Deferred tool discovery',
build: vi.fn((params: Record<string, unknown>) => ({ params })),
};
const target = {
name: 'mcp__github__create_issue',
kind: core.Kind.Other,
displayName: 'CreateIssue',
description: 'Creates an issue',
canUpdateOutput: false,
isOutputMarkdown: false,
build: vi.fn().mockImplementation((params) => ({
params,
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('create issue'),
toolLocations: vi.fn().mockReturnValue([]),
execute,
})),
};
if (outcome === 'build error') {
target.build.mockImplementation(() => {
throw new Error("params must have required property 'title'.");
});
} else if (outcome === 'execute error') {
execute.mockRejectedValue(new Error('Remote service unavailable'));
}
).runToolCalls(
new AbortController().signal,
'prompt-tool-call-bridge',
[
{
id: 'bridge-call',
name: core.ToolNames.TOOL_CALL,
args: {
name: target.name,
arguments: { title: 'Cache-safe tools' },
mockToolRegistry.getTool.mockImplementation((name: string) =>
name === bridge.name
? bridge
: name === target.name
? target
: name === toolSearch.name
? toolSearch
: undefined,
);
mockToolRegistry.ensureTool.mockImplementation(
async (name: string) =>
name === bridge.name
? bridge
: name === target.name
? target
: name === toolSearch.name
? toolSearch
: undefined,
);
mockToolRegistry.isDeferredAndHidden.mockImplementation(
(name: string) => name === target.name,
);
const toolLoopState = {
totalToolCalls: 0,
invalidToolParamErrors: new Map<string, number>(),
toolCallKeyCounts: new Map<string, number>(),
maxToolCallKeyRepeat: 0,
loopDetected: false,
};

const result = await (
session as unknown as {
runToolCalls: (
abortSignal: AbortSignal,
promptId: string,
calls: FunctionCall[],
loopState: typeof toolLoopState,
) => Promise<{ parts: Part[] }>;
}
).runToolCalls(
new AbortController().signal,
'prompt-tool-call-bridge',
[
{
id: 'bridge-call',
name: core.ToolNames.TOOL_CALL,
args: {
name: target.name,
arguments: { title: 'Cache-safe tools' },
},
},
},
],
toolLoopState,
);
],
toolLoopState,
);

expect(execute).toHaveBeenCalledOnce();
expect(target.build).toHaveBeenCalledWith({
title: 'Cache-safe tools',
});
expect(result.parts[0]?.functionResponse).toMatchObject({
id: 'bridge-call',
name: core.ToolNames.TOOL_CALL,
response: { output: 'created issue' },
});
expect(mockLlmClient.recordCompletedToolCall).toHaveBeenCalledWith(
target.name,
{ title: 'Cache-safe tools' },
);
});
expect(execute).toHaveBeenCalledTimes(
outcome === 'build error' ? 0 : 1,
);
expect(target.build).toHaveBeenCalledWith({
title: 'Cache-safe tools',
});
expect(result.parts[0]?.functionResponse).toMatchObject({
id: 'bridge-call',
name: core.ToolNames.TOOL_CALL,
response:
outcome === 'success'
? { output: 'created issue' }
: {
error:
outcome === 'build error'
? `Deferred tool "${target.name}" (called through tool_call) rejected the arguments: params must have required property 'title'. Pass arguments matching the schema returned by tool_search for "${target.name}".`
: 'Remote service unavailable',
},
});
expect(mockLlmClient.recordCompletedToolCall).toHaveBeenCalledWith(
target.name,
{ title: 'Cache-safe tools' },
);
},
);

it('marks a disabled ACP tool_call as a bridge refusal', async () => {
mockConfig.getPermissionManager = vi.fn().mockReturnValue({
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
ToolErrorType,
DEFERRED_TOOL_CALL_REFUSAL_PREFIX,
DEFERRED_TOOL_CALL_CANCELLATION_PREFIX,
describeBridgedArgumentError,
resolveDeferredToolCall,
CreateSubSessionTool,
fireNotificationHook,
Expand Down Expand Up @@ -13768,6 +13769,7 @@ export class Session implements SessionContext {
}

let toolName = fc.name;
let bridgedThroughToolCall = false;
if (
!appExecution &&
this.config.getToolMode?.() === ToolMode.CodeModeOnly &&
Expand Down Expand Up @@ -13842,6 +13844,7 @@ export class Session implements SessionContext {
toolName = resolution.tool.name;
args = resolution.arguments;
tool = resolution.tool;
bridgedThroughToolCall = true;
}

if (!tool) {
Expand Down Expand Up @@ -16034,7 +16037,15 @@ export class Session implements SessionContext {
} catch (e) {
// No failure to report: see the outer catch.
if (e instanceof ManagedRuntimeOutcomeUnknownError) throw e;
const error = e instanceof Error ? e : new Error(String(e));
const caught = e instanceof Error ? e : new Error(String(e));
// Same labelling as the scheduler: a target reached through
// tool_call names itself when its own build() rejects the arguments.
const error =
bridgedThroughToolCall && !toolBuildSucceeded
? new Error(
describeBridgedArgumentError(toolName, caught.message),
)
: caught;
const hooksEnabledForError = !this.config.getDisableAllHooks?.();
const messageBusForError = this.config.getMessageBus?.();
const executionTimeoutException =
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,12 @@ describe('CoreToolScheduler', () => {
},
);

const URL_REQUIRED_PARAMS = {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
};

/** tool_call bridge + hidden deferred MockTool (mcp__github__create_issue). */
function bridgeWithDeferred(
deferredOptions: Partial<ConstructorParameters<typeof MockTool>[0]> = {},
Expand Down Expand Up @@ -1674,6 +1680,80 @@ describe('CoreToolScheduler', () => {
expect(functionResponseOf(third)?.name).toBe(ToolNames.TOOL_CALL);
});

it('names the target when a bridged call fails its own validation (#12889)', async () => {
const { completed, deferred } = await runBridgeCall('bridge-invalid-args', {
params: URL_REQUIRED_PARAMS,
});

expectStatus(completed, 'error');
expect(completed.response.errorType).toBe(
ToolErrorType.INVALID_TOOL_PARAMS,
);
expect(functionResponseOf(completed)?.name).toBe(ToolNames.TOOL_CALL);
const message = completed.response.error?.message ?? '';
expect(message).toContain(`Deferred tool "${deferred.name}"`);
expect(message).toContain("must have required property 'url'");
expect(message).toContain(ToolNames.TOOL_SEARCH);
});

it("leaves a direct call's validation error unlabelled", async () => {
const direct = new MockTool({
name: 'needs_url',
params: URL_REQUIRED_PARAMS,
});
const { scheduler, onAllToolCallsComplete } =
createSchedulerForLegacyToolTests({ toolsByName: toolMap(direct) });

await scheduler.schedule(
toolRequest('direct-invalid-args', direct.name, {}, 'prompt-direct'),
new AbortController().signal,
);

const completed = firstBatch(onAllToolCallsComplete)[0];
expectStatus(completed, 'error');
const message = completed.response.error?.message ?? '';
expect(message).toContain("must have required property 'url'");
expect(message).not.toContain('Deferred tool');
});

it('shares validation retries across bridged and direct calls', async () => {
const { deferred, scheduler, onAllToolCallsComplete } = bridgeWithDeferred({
params: URL_REQUIRED_PARAMS,
});

for (const [index, name] of [
ToolNames.TOOL_CALL,
deferred.name,
ToolNames.TOOL_CALL,
].entries()) {
onAllToolCallsComplete.mockClear();
await scheduler.schedule(
toolRequest(
`mixed-validation-${index}`,
name,
name === ToolNames.TOOL_CALL
? { name: deferred.name, arguments: {} }
: {},
'prompt-mixed-validation',
),
new AbortController().signal,
);

const completed = firstBatch(onAllToolCallsComplete)[0];
expectStatus(completed, 'error');
expect(completed.response.errorType).toBe(
ToolErrorType.INVALID_TOOL_PARAMS,
);
expect(functionResponseOf(completed)?.name).toBe(name);
const message = completed.response.error?.message ?? '';
expect(message).toContain("must have required property 'url'");
expect(message.includes('Deferred tool')).toBe(
name === ToolNames.TOOL_CALL,
);
expect(message.includes('RETRY LOOP DETECTED')).toBe(index === 2);
}
});

it('prunes the bridge-keyed retry counter across a successful bridged execution', async () => {
// R1-18: invalid envelopes record under the model-facing name
// (`tool_call:<msg>`), but a resolved envelope is renamed to the TARGET
Expand Down
Loading
Loading