Skip to content

Commit b3ff641

Browse files
authored
fix(codex): classify accepted/created/updated dynamic-tool statuses as success (#96856)
isCodexToolResultError fail-closes every tool-result status not in its non-error allowlist, but the allowlist omitted several success statuses emitted by OpenClaw tools that are exposed to codex agents: - sessions_spawn accepted launches -> details.status "accepted" - create_goal / update_goal results -> details.status "created" / "updated" So a successful accepted spawn (#96833), and successful goal create/update, were classified as errors: reported to codex as success: false (mapped to a Failed item status) and persisted on the transcript as isError: true. This adds those statuses to the allowlist alongside their sibling success statuses (completed/recorded/started/running). Genuinely failed or forbidden results (status "error"/"forbidden") stay fail-closed. Adds regression tests: accepted spawn and created/updated goal results are reported as successful dynamic tool calls; a forbidden spawn still fails.
1 parent 31b531e commit b3ff641

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

extensions/codex/src/app-server/dynamic-tools.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,100 @@ describe("createCodexDynamicToolBridge", () => {
308308
});
309309
});
310310

311+
it("treats an accepted child session spawn result as a successful dynamic tool call", async () => {
312+
// An accepted sessions_spawn launch carries details.status "accepted" with a
313+
// runId + childSessionKey. The launch succeeded (the child session was
314+
// accepted), so Codex must see a successful tool call, not an error.
315+
// Regression for #96833: "accepted" was missing from the non-error status
316+
// allowlist, so the launch was classified as an error and persisted with
317+
// isError: true (and reported to Codex as success: false).
318+
const onAgentToolResult = vi.fn();
319+
const bridge = createBridgeWithToolResult(
320+
"sessions_spawn",
321+
textToolResult("Accepted: launching child session to scan logs.", {
322+
status: "accepted",
323+
runId: "run_5f3a9c",
324+
childSessionKey: "child-7b21",
325+
mode: "run",
326+
}),
327+
);
328+
329+
const result = await bridge.handleToolCall(
330+
{
331+
threadId: "thread-1",
332+
turnId: "turn-1",
333+
callId: "call-accepted",
334+
namespace: null,
335+
tool: "sessions_spawn",
336+
arguments: { task: "scan logs" },
337+
},
338+
{ onAgentToolResult },
339+
);
340+
341+
// success: true proves the accepted launch is not classified as an error;
342+
// the content assertion proves the tool actually executed (not a denial path).
343+
expect(result.success).toBe(true);
344+
expect(result.contentItems).toEqual([
345+
{ type: "inputText", text: "Accepted: launching child session to scan logs." },
346+
]);
347+
expect(onAgentToolResult).toHaveBeenCalledWith(
348+
expect.objectContaining({ toolName: "sessions_spawn", isError: false }),
349+
);
350+
});
351+
352+
it("still reports a forbidden sessions_spawn result as a failed dynamic tool call", async () => {
353+
// Deny symmetry: a genuinely rejected spawn (status "forbidden") must stay an
354+
// error so the accepted-status allowlist entry does not over-correct.
355+
const bridge = createBridgeWithToolResult(
356+
"sessions_spawn",
357+
textToolResult("Forbidden: spawn limit reached.", { status: "forbidden" }),
358+
);
359+
360+
const result = await bridge.handleToolCall({
361+
threadId: "thread-1",
362+
turnId: "turn-1",
363+
callId: "call-forbidden",
364+
namespace: null,
365+
tool: "sessions_spawn",
366+
arguments: { task: "deploy" },
367+
});
368+
369+
expect(result.success).toBe(false);
370+
});
371+
372+
it("treats accepted goal tool statuses (created / updated) as successful dynamic tool calls", async () => {
373+
// Same classifier-completeness class as the accepted spawn fix: create_goal /
374+
// update_goal return details.status "created" / "updated", reach codex agents
375+
// through the dynamic-tool bridge, and must not be classified as errors (#96833).
376+
const createdBridge = createBridgeWithToolResult(
377+
"create_goal",
378+
textToolResult("Goal created.", { status: "created" }),
379+
);
380+
const createdResult = await createdBridge.handleToolCall({
381+
threadId: "thread-1",
382+
turnId: "turn-1",
383+
callId: "call-created",
384+
namespace: null,
385+
tool: "create_goal",
386+
arguments: { text: "ship the fix" },
387+
});
388+
expect(createdResult.success).toBe(true);
389+
390+
const updatedBridge = createBridgeWithToolResult(
391+
"update_goal",
392+
textToolResult("Goal updated.", { status: "updated" }),
393+
);
394+
const updatedResult = await updatedBridge.handleToolCall({
395+
threadId: "thread-1",
396+
turnId: "turn-1",
397+
callId: "call-updated",
398+
namespace: null,
399+
tool: "update_goal",
400+
arguments: { status: "completed" },
401+
});
402+
expect(updatedResult.success).toBe(true);
403+
});
404+
311405
it("keeps available and registered schemas paired with their tools", () => {
312406
const bridge = createCodexDynamicToolBridge({
313407
tools: [

extensions/codex/src/app-server/dynamic-tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,9 @@ function isCodexToolResultError(result: AgentToolResult<unknown>): boolean {
11841184
status !== "success" &&
11851185
status !== "completed" &&
11861186
status !== "recorded" &&
1187+
status !== "created" &&
1188+
status !== "updated" &&
1189+
status !== "accepted" &&
11871190
status !== "pending" &&
11881191
status !== "started" &&
11891192
status !== "running" &&

0 commit comments

Comments
 (0)