Skip to content

fix: preserve headless tool-call failure truth - #116

Merged
byapparov merged 3 commits into
mainfrom
backport/headless-truth
Sep 30, 2026
Merged

byapparov merged 3 commits into
mainfrom
backport/headless-truth

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Relates to #89

Intent

A model can return a terminal-looking stop after a local tool call, and a child task can fail while its parent only sees empty text. Those cases can terminate headless execution with incomplete truth.

Expected Impact on Users

Headless runs continue after ordinary local tool calls when structured output is still pending, and failed child tasks are surfaced to the parent instead of being presented as successful empty output.

Expected Outcomes

  • Tool calls that were not provider-executed prevent premature terminal exit.
  • Structured-output turns continue through local tool execution and validate the subsequent model response.
  • Child assistant and tool failures retain task identity and propagate as actionable errors.

Implementation

  • Persist provider-executed attribution on tool parts and use it in prompt continuation guards.
  • Preserve child failure details in TaskTool results while retaining the existing success output shape.

Scope Caveat

This prepares the malformed-tool-call recovery work in #111; it does not ship automatic retries or claim live recovery effectiveness.

Test Plan

  • Local tool-call and structured-output subprocess fixtures cover continuation after a stop reason.
  • Child assistant/tool failure tests cover attribution and the existing successful task format.

Verification

  • 41 focused tests passed.
  • CLI typecheck, formatting, and diff checks passed.

Risks and Rollout

The change is limited to headless continuation and child-task error propagation. No event schema removal or retry policy change is involved.

@byapparov byapparov added this to the Enterprise Observability milestone Sep 14, 2026
Comment thread packages/cli/src/tool/task.ts
Comment thread packages/cli/src/session/processor.ts
Comment thread packages/cli/src/session/processor.ts
Comment thread packages/cli/src/session/prompt.ts Outdated
Comment thread packages/cli/src/tool/task.ts
Comment thread packages/cli/src/session/prompt.ts Outdated
Comment thread packages/cli/src/tool/task.ts
@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 4 · ⚪ 2 · 0/7 resolved

  • 🟡 packages/cli/src/session/processor.ts:148-150 — providerExecuted magic string lacks shared constant
  • 🟡 packages/cli/src/session/processor.ts:148-151 — providerMetadata can forge providerExecuted flag
  • ⚪ packages/cli/src/session/prompt.ts:717 — Finish-reason filter duplicated; extract helper
  • 🟡 packages/cli/src/session/prompt.ts:718-720 — Parts query runs even when model not finished
  • 🟠 packages/cli/src/tool/task.ts:30-31 — taskResultText crashes when error.data is undefined
  • ⚪ packages/cli/src/tool/task.ts:34-35 — Duplicated tool error check; use type predicate
  • 🟡 packages/cli/src/tool/task.ts:163-165 — agent.subtask.complete skipped on child failure
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #116 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/session/processor.ts:148-150 — providerExecuted magic string lacks shared constant
   Detail: The `providerExecuted` key is written into tool-part metadata in processor.ts and read back with a magic string in prompt.ts (`part.metadata?.providerExecuted`, packages/cli/src/session/prompt.ts:66), creating an implicit cross-file contract with no shared constant or typed field. The new tests also hardcode the string. A typo or rename on either side would silently disable the headless-truth logic with no compiler error.
   Suggested fix: Export a shared constant (e.g. `MessageV2.PROVIDER_EXECUTED_METADATA_KEY = "providerExecuted"`) or add a typed optional field on ToolPart, and use it in both processor.ts (write), prompt.ts (read), and the tests.
2. packages/cli/src/session/processor.ts:148-151 — providerMetadata can forge providerExecuted flag
   Detail: When `value.providerExecuted` is falsy, the persisted metadata is just `{ ...value.providerMetadata }`; a provider-supplied top-level key `providerExecuted` inside providerMetadata survives verbatim. SessionPrompt.hasToolCalls (packages/cli/src/session/prompt.ts:66) then treats the tool call as provider-executed, so the prompt loop can exit without executing the tool or taking the follow-up model turn — silently dropping a local tool call and, in json_schema mode, raising StructuredOutputError instead of continuing. Repro: given a provider adapter that surfaces a top-level `providerExecuted: true` entry in a tool-call part's providerMetadata while the part's own providerExecuted flag is false, when the model streams that local tool call and finishes with reason "stop", then the CLI persists metadata.providerExecuted=true, hasToolCalls returns false, and the loop exits without executing the tool.
   Suggested fix: Overwrite the key unconditionally instead of conditionally spreading, e.g. `metadata: { ...value.providerMetadata, providerExecuted: value.providerExecuted === true }` (or strip any incoming `providerExecuted` key from providerMetadata before merging), so the flag can only come from the stream part itself.
3. packages/cli/src/session/prompt.ts:717 — Finish-reason filter duplicated; extract helper
   Detail: This PR extracts hasToolCalls as a shared helper, but the adjacent finish-reason filter `!["tool-calls", "unknown"].includes(finish)` still appears twice in prompt.ts (the loop-exit condition around line 342 and the modelFinished computation at line 717). Extracting both predicates keeps the two exit paths symmetric and single-sourced; the two sites must stay in agreement for the loop logic to be correct.
   Suggested fix: Add `export function isModelFinished(finish?: string) { return !!finish && !["tool-calls", "unknown"].includes(finish) }` next to hasToolCalls and use it in both the loop-exit condition and the modelFinished const.
4. packages/cli/src/session/prompt.ts:718-720 — Parts query runs even when model not finished
   Detail: hasCurrentToolCalls awaits `MessageV2.parts(processor.message.id)` unconditionally on every iteration of the structured-output retry loop, even when `modelFinished` is false or `processor.message.error` is set and the value is never used. This is inconsistent with the short-circuit `&&` chain it feeds and adds a redundant async persistence read per retry turn.
   Suggested fix: Short-circuit inside the condition so the await only runs when the other guards pass: `if (modelFinished && !processor.message.error && !hasToolCalls(await MessageV2.parts(processor.message.id))) { ... }`, or compute hasCurrentToolCalls inside an `if (modelFinished)` guard.
5. packages/cli/src/tool/task.ts:30-31 — taskResultText crashes when error.data is undefined
   Detail: taskResultText reads `const data = result.info.error.data` and then applies `"message" in data` without guarding against `data` being undefined or a non-object. Persisted child errors whose serialized shape lacks a `data` field (or carries a non-object data) make the `in` operator throw `TypeError: Cannot use 'in' operator`, replacing the intended "Subagent failed (task_id: ...): <reason>" message with a confusing TypeError — undermining this PR's goal of surfacing the real child failure cause. The added test only covers `MessageV2.APIError.toObject()`, which happens to include `data`, so the gap is untested. Repro: given a subagent whose final assistant message persists an error without a `data` payload, when the parent runs the Task tool, then taskResultText throws the raw TypeError instead of the child's actual error name/message.
   Suggested fix: Guard the operand before using `in`: `const data = result.info.error.data as { message?: string } | undefined` then `const message = data && typeof data.message === "string" ? data.message : result.info.error.name` (or `"message" in (data ?? {})`). Add a test for an error serialized without `data`.
6. packages/cli/src/tool/task.ts:34-35 — Duplicated tool error check; use type predicate
   Detail: The findLast predicate and the immediately following if condition repeat the same compound check (`part.type === "tool" && part.state.status === "error"`) purely for TypeScript narrowing. A type predicate on findLast removes the duplicated condition.
   Suggested fix: Use a type-guard predicate: `const failed = result.parts.findLast((part): part is MessageV2.ToolPart => part.type === "tool" && part.state.status === "error")` then `if (failed) { ... }`.
7. packages/cli/src/tool/task.ts:163-165 — agent.subtask.complete skipped on child failure
   Detail: taskResultText now throws on child failure before `await Plugin.trigger("agent.subtask.complete", ...)` executes, so plugins subscribed to subtask completion never observe failed subtasks, and any post-trigger accounting on this path is skipped. Previously the event fired for every completed subtask regardless of outcome; now failed subtasks become invisible to plugin-based stats/notifications. Repro: given a plugin subscribed to "agent.subtask.complete", when a child subagent fails (message-level error or error tool part), then taskResultText throws before Plugin.trigger runs and the plugin never receives the event.
   Suggested fix: Trigger `agent.subtask.complete` (with a success/error flag) before throwing, or wrap the taskResultText call so the plugin event fires in a finally block on the failure path.
📋 Out-of-diff findings (7)
Sev Location Finding
🟡 packages/cli/src/session/processor.ts:148-150 providerExecuted magic string lacks shared constant
🟡 packages/cli/src/session/processor.ts:148-151 providerMetadata can forge providerExecuted flag
⚪ packages/cli/src/session/prompt.ts:717 Finish-reason filter duplicated; extract helper
🟡 packages/cli/src/session/prompt.ts:718-720 Parts query runs even when model not finished
🟠 packages/cli/src/tool/task.ts:30-31 taskResultText crashes when error.data is undefined
⚪ packages/cli/src/tool/task.ts:34-35 Duplicated tool error check; use type predicate
🟡 packages/cli/src/tool/task.ts:163-165 agent.subtask.complete skipped on child failure

Reviewed 6 files · 0 inline · view all 7 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov
byapparov force-pushed the backport/headless-truth branch from 532f00f to 4a96e51 Compare September 30, 2026 14:00
@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #116

All seven findings from the latest automated review have a verdict. Six are fixed in 4a96e51 (rebased onto current main, which includes #122 and #119). One is verified false.

Issues addressed (pushed to this PR)

  • taskResultText crashes when error.data is undefined — packages/cli/src/tool/task.ts: data is checked before reading message, and the fallback is the child error name. A test covers missing and malformed data. (commit 4a96e51)
  • providerExecuted magic string has no shared constant — packages/cli/src/session/message-v2.ts, processor.ts, prompt.ts: added MessageV2.PROVIDER_EXECUTED_METADATA_KEY and used it for the write and the loop checks. The persistence test pins the serialized key. (commit 4a96e51)
  • providerMetadata can forge the providerExecuted flag — packages/cli/src/session/processor.ts: the flag is now written as value.providerExecuted === true after the provider metadata is spread, so a false stream flag overrides a forged true value. (commit 4a96e51)
  • Parts query runs even when the model has not finished — packages/cli/src/session/prompt.ts: the new missingStructuredOutput loads parts only after an error-free finish, and it runs only for JSON schema output. (commit 4a96e51)
  • agent.subtask.complete is skipped when the child fails — packages/cli/src/tool/task.ts: completeTask fires the event before taskResultText can throw. The { result: string } hook payload is unchanged. (commit 4a96e51)
  • Finish-reason filter is duplicated — packages/cli/src/session/prompt.ts: added SessionPrompt.isModelFinished, which the loop exit and the structured-output check both use. (commit 4a96e51)

Also aligned with #122 (delivered tool calls): a local tool call that ended with partial pending or running input and a synthetic Tool execution aborted error no longer counts as an outstanding local call. A delivered local call still counts, even if it aborts later. The internal delivery flag is removed from callProviderMetadata before messages are converted for the AI SDK, because otherwise the next turn fails SDK prompt validation. Tests cover this.

Verification: bun run test in packages/cli passes (1,483 passed, 7 skipped, 0 failed). bun turbo typecheck passes (6/6). For each of 8 targeted mutations, reverting that fix makes its test fail.

Review claims verified false (no change needed)

  • "Duplicated tool error check; use type predicate" — verified false. The suggested (part): part is MessageV2.ToolPart narrows only the part type. It does not narrow state.status to error, so failed.state.error would not type-check without the second check. The repeated condition is what does the narrowing, and it has no behavioural defect.

Not addressed here

  • None.

@byapparov
byapparov merged commit 6b8c8ee into main Sep 30, 2026
5 checks passed
@byapparov
byapparov deleted the backport/headless-truth branch September 30, 2026 14:02
@byapparov
byapparov restored the backport/headless-truth branch September 30, 2026 14:03
},
},
metadata: value.providerMetadata,
metadata: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Persisted providerExecuted key skips SDK/share consumers.

Suggested change
metadata: {
Audit part.metadata consumers: confirm SDK generated types (packages/sdk/src/gen/types.gen.ts) and share/export part schemas treat metadata as an open Record<string, JSONValue>, regenerating types if needed; if the flag is meant to stay internal, consider stripping it (like toolProviderMetadata does) or moving it to a dedicated typed field on ToolPart instead of the generic metadata bag.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/processor.ts:189-192):

Problem: Persisted providerExecuted key skips SDK/share consumers
Detail: Every new ToolPart now persists metadata.providerExecuted, but the coupled neighbours that consume persisted part metadata are untouched in this PR: packages/sdk/src/gen/types.gen.ts is a weight-1.0 co-change partner of both edited files in recent PRs, and share/export surfaces (e.g. src/share/share-next.ts, which imports MessageV2) serialize part metadata verbatim. If any of those schemas type ToolPart.metadata as a closed shape rather than an open record, serialized sessions carrying the new key fail validation; at minimum an internal execution flag is now exposed on shared/exported session payloads with no scrubbing (toolProviderMetadata strips it only at the toModelMessages boundary).
Suggested fix: Audit part.metadata consumers: confirm SDK generated types (packages/sdk/src/gen/types.gen.ts) and share/export part schemas treat metadata as an open Record<string, JSONValue>, regenerating types if needed; if the flag is meant to stay internal, consider stripping it (like toolProviderMetadata does) or moving it to a dedicated typed field on ToolPart instead of the generic metadata bag.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Every new ToolPart now persists metadata.providerExecuted, but the coupled neighbours that consume persisted part metadata are untouched in this PR: packages/sdk/src/gen/types.gen.ts is a weight-1.0 co-change partner of both edited files in recent PRs, and share/export surfaces (e.g. src/share/share-next.ts, which imports MessageV2) serialize part metadata verbatim. If any of those schemas type ToolPart.metadata as a closed shape rather than an open record, serialized sessions carrying the new key fail validation; at minimum an internal execution flag is now exposed on shared/exported session payloads with no scrubbing (toolProviderMetadata strips it only at the toModelMessages boundary).

                      metadata: {
                        ...value.providerMetadata,
                        [MessageV2.PROVIDER_EXECUTED_METADATA_KEY]: value.providerExecuted === true,
                      },
                    })
                    toolcalls[value.toolCallId] = part as MessageV2.ToolPart
                    parts.add(part.id)

return parts.some(
(part) =>
part.type === "tool" &&
part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] !== true &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Legacy parts default to client-executed in hasToolCalls.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/prompt.ts:69-75):

Problem: Legacy parts default to client-executed in hasToolCalls
Detail: The gate reads part.metadata?.[PROVIDER_EXECUTED_METADATA_KEY] with two different comparisons: `!== true` for the completed branch but `=== false` for the aborted branch. Tool parts persisted before this PR never carry the key (metadata was just value.providerMetadata), so a legacy completed tool part is always classified as client-executed. On resume/retry of a pre-PR session whose last assistant message has a terminal finish plus completed tool parts, the top-of-loop check that previously exited now returns hasToolCalls=true and triggers an extra model turn that re-sends tool results — including results for tools the provider already executed server-side, which some providers reject. The undefined-vs-false asymmetry between the two branches is implicit rather than a documented tri-state decision.
Suggested fix: Make the tri-state explicit: normalize once (`const executed = part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY]`) and decide deliberately how `undefined` (legacy parts) behaves per status branch — e.g. treat undefined as client-executed for completed parts only if that resume behavior is intended, otherwise fall back to the pre-PR finish-only exit for parts without the marker.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The gate reads part.metadata?.[PROVIDER_EXECUTED_METADATA_KEY] with two different comparisons: !== true for the completed branch but === false for the aborted branch. Tool parts persisted before this PR never carry the key (metadata was just value.providerMetadata), so a legacy completed tool part is always classified as client-executed. On resume/retry of a pre-PR session whose last assistant message has a terminal finish plus completed tool parts, the top-of-loop check that previously exited now returns hasToolCalls=true and triggers an extra model turn that re-sends tool results — including results for tools the provider already executed server-side, which some providers reject. The undefined-vs-false asymmetry between the two branches is implicit rather than a documented tri-state decision.

  export function hasToolCalls(parts: MessageV2.Part[]) {
    return parts.some(
      (part) =>
        part.type === "tool" &&
        part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] !== true &&
        (part.state.status === "completed" ||
          (part.state.status === "error" &&
            (part.state.error !== MessageV2.TOOL_EXECUTION_ABORTED ||
              part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] === false))),
    )
  }

? data.message
: result.info.error.name
throw new Error(`Subagent failed (task_id: ${sessionID}): ${message}`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Trailing orphan error part discards valid child text.

Suggested change
}
Only throw when the errored tool part has no subsequent text answer in the message (or tolerate parts whose error is MessageV2.TOOL_EXECUTION_ABORTED, mirroring hasToolCalls), otherwise return the text — optionally annotated with the failure — so the parent keeps the child's answer while still surfacing genuine failures.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:36-39):

Problem: Trailing orphan error part discards valid child text
Detail: taskResultText hard-fails the subagent when the child's final message contains ANY error-status tool part, even when the same message also carries the model's textual answer. The prompt layer added in this PR treats exactly such a case as a clean stop: an orphan partial tool input (tool-input-start with no tool-call, finish "stop") is persisted as an errored part while the loop exits after one model call with no message.error — the PR's own prompt-tool-loop test pins that behavior. The two layers therefore disagree: prompt says "terminal but clean", TaskTool throws "Subagent failed (task_id: …)", and the parent model loses the child's valid text for what may be a provider truncation glitch.
Suggested fix: Only throw when the errored tool part has no subsequent text answer in the message (or tolerate parts whose error is MessageV2.TOOL_EXECUTION_ABORTED, mirroring hasToolCalls), otherwise return the text — optionally annotated with the failure — so the parent keeps the child's answer while still surfacing genuine failures.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

taskResultText hard-fails the subagent when the child's final message contains ANY error-status tool part, even when the same message also carries the model's textual answer. The prompt layer added in this PR treats exactly such a case as a clean stop: an orphan partial tool input (tool-input-start with no tool-call, finish "stop") is persisted as an errored part while the loop exits after one model call with no message.error — the PR's own prompt-tool-loop test pins that behavior. The two layers therefore disagree: prompt says "terminal but clean", TaskTool throws "Subagent failed (task_id: …)", and the parent model loses the child's valid text for what may be a provider truncation glitch.

  const failed = result.parts.findLast((part) => part.type === "tool" && part.state.status === "error")
  if (failed?.type === "tool" && failed.state.status === "error") {
    throw new Error(`Subagent failed (task_id: ${sessionID}): ${failed.state.error}`)
  }
  return result.parts.findLast((part) => part.type === "text")?.text ?? ""
}

return result.parts.findLast((part) => part.type === "text")?.text ?? ""
}

export async function completeTask(result: MessageV2.WithParts, subagentSessionID: string, parentSessionID: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Last-text extraction duplicated in task helpers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:44-46):

Problem: Last-text extraction duplicated in task helpers
Detail: completeTask computes `result.parts.findLast((part) => part.type === "text")?.text ?? ""` for the plugin payload, then delegates to taskResultText which repeats the identical expression for its return value. The two copies can drift if text selection ever changes (e.g. trimming or filtering empty texts) and the duplication is avoidable.
Suggested fix: Extract a single helper (e.g. `const lastText = (parts) => parts.findLast((p) => p.type === "text")?.text ?? ""`) and use it in both completeTask and taskResultText, or have taskResultText accept the precomputed text.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

completeTask computes result.parts.findLast((part) => part.type === "text")?.text ?? "" for the plugin payload, then delegates to taskResultText which repeats the identical expression for its return value. The two copies can drift if text selection ever changes (e.g. trimming or filtering empty texts) and the duplication is avoidable.

export async function completeTask(result: MessageV2.WithParts, subagentSessionID: string, parentSessionID: string) {
  const text = result.parts.findLast((part) => part.type === "text")?.text ?? ""
  await Plugin.trigger("agent.subtask.complete", { subagentSessionID, parentSessionID }, { result: text })
  return taskResultText(result, subagentSessionID)
}

@aictrl-dev

aictrl-dev Bot commented Sep 30, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 3 · ⚪ 1 · 0/4 resolved

  • 🟡 packages/cli/src/session/processor.ts:189-192 — Persisted providerExecuted key skips SDK/share consumers
  • 🟡 packages/cli/src/session/prompt.ts:69-75 — Legacy parts default to client-executed in hasToolCalls
  • 🟡 packages/cli/src/tool/task.ts:36-39 — Trailing orphan error part discards valid child text
  • ⚪ packages/cli/src/tool/task.ts:44-46 — Last-text extraction duplicated in task helpers
🤖 Fix all 4 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #116 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/session/processor.ts:189-192 — Persisted providerExecuted key skips SDK/share consumers
   Detail: Every new ToolPart now persists metadata.providerExecuted, but the coupled neighbours that consume persisted part metadata are untouched in this PR: packages/sdk/src/gen/types.gen.ts is a weight-1.0 co-change partner of both edited files in recent PRs, and share/export surfaces (e.g. src/share/share-next.ts, which imports MessageV2) serialize part metadata verbatim. If any of those schemas type ToolPart.metadata as a closed shape rather than an open record, serialized sessions carrying the new key fail validation; at minimum an internal execution flag is now exposed on shared/exported session payloads with no scrubbing (toolProviderMetadata strips it only at the toModelMessages boundary).
   Suggested fix: Audit part.metadata consumers: confirm SDK generated types (packages/sdk/src/gen/types.gen.ts) and share/export part schemas treat metadata as an open Record<string, JSONValue>, regenerating types if needed; if the flag is meant to stay internal, consider stripping it (like toolProviderMetadata does) or moving it to a dedicated typed field on ToolPart instead of the generic metadata bag.
2. packages/cli/src/session/prompt.ts:69-75 — Legacy parts default to client-executed in hasToolCalls
   Detail: The gate reads part.metadata?.[PROVIDER_EXECUTED_METADATA_KEY] with two different comparisons: `!== true` for the completed branch but `=== false` for the aborted branch. Tool parts persisted before this PR never carry the key (metadata was just value.providerMetadata), so a legacy completed tool part is always classified as client-executed. On resume/retry of a pre-PR session whose last assistant message has a terminal finish plus completed tool parts, the top-of-loop check that previously exited now returns hasToolCalls=true and triggers an extra model turn that re-sends tool results — including results for tools the provider already executed server-side, which some providers reject. The undefined-vs-false asymmetry between the two branches is implicit rather than a documented tri-state decision.
   Suggested fix: Make the tri-state explicit: normalize once (`const executed = part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY]`) and decide deliberately how `undefined` (legacy parts) behaves per status branch — e.g. treat undefined as client-executed for completed parts only if that resume behavior is intended, otherwise fall back to the pre-PR finish-only exit for parts without the marker.
3. packages/cli/src/tool/task.ts:36-39 — Trailing orphan error part discards valid child text
   Detail: taskResultText hard-fails the subagent when the child's final message contains ANY error-status tool part, even when the same message also carries the model's textual answer. The prompt layer added in this PR treats exactly such a case as a clean stop: an orphan partial tool input (tool-input-start with no tool-call, finish "stop") is persisted as an errored part while the loop exits after one model call with no message.error — the PR's own prompt-tool-loop test pins that behavior. The two layers therefore disagree: prompt says "terminal but clean", TaskTool throws "Subagent failed (task_id: …)", and the parent model loses the child's valid text for what may be a provider truncation glitch.
   Suggested fix: Only throw when the errored tool part has no subsequent text answer in the message (or tolerate parts whose error is MessageV2.TOOL_EXECUTION_ABORTED, mirroring hasToolCalls), otherwise return the text — optionally annotated with the failure — so the parent keeps the child's answer while still surfacing genuine failures.
4. packages/cli/src/tool/task.ts:44-46 — Last-text extraction duplicated in task helpers
   Detail: completeTask computes `result.parts.findLast((part) => part.type === "text")?.text ?? ""` for the plugin payload, then delegates to taskResultText which repeats the identical expression for its return value. The two copies can drift if text selection ever changes (e.g. trimming or filtering empty texts) and the duplication is avoidable.
   Suggested fix: Extract a single helper (e.g. `const lastText = (parts) => parts.findLast((p) => p.type === "text")?.text ?? ""`) and use it in both completeTask and taskResultText, or have taskResultText accept the precomputed text.
📋 Out-of-diff findings (4)
Sev Location Finding
🟡 packages/cli/src/session/processor.ts:189-192 Persisted providerExecuted key skips SDK/share consumers
🟡 packages/cli/src/session/prompt.ts:69-75 Legacy parts default to client-executed in hasToolCalls
🟡 packages/cli/src/tool/task.ts:36-39 Trailing orphan error part discards valid child text
⚪ packages/cli/src/tool/task.ts:44-46 Last-text extraction duplicated in task helpers

Reviewed 7 files · 0 inline · view all 4 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant