When OffensiveSecurityAgent.consume() (packages/apex/src/core/agents/offSecAgent/offensiveSecurityAgent.ts:449-458) is iterating streamResult.fullStream and the iterator throws — AbortSignal fired (endpoint timer, scan-level timer, user abort), idle-stream timeout, or a transport error — any tool whose tool-call-complete was emitted but whose tool-result had not yet streamed is silently dropped. The bus emits nothing for it on the way out. Downstream in-process consumers (TUI rendering, session persistence on disk, and any external subscribers attached to a parent bus) are left with a tool-call that has no matching result.
Proposal
Track in-flight tool calls inside consume() (saw tool-call-complete for a toolCallId but not yet tool-result). On stream abort/exception, emit synthetic tool-result events for each, reusing the existing AI SDK output envelope:
bus.emit('tool-result', {
toolCallId,
toolName,
result: {
type: 'error-text',
value: `Tool execution aborted: ${abortReason}. ${err.message ?? ''}`,
},
subagentId: this.subagentId,
});
error-text (and its sibling error-json) is already a first-class variant in the SDK's tool-output discriminated union (packages/apex/src/core/messages/types.ts:61-77) and is already handled across the codebase:
packages/apex/src/core/session/persistence.ts:419 (unwrapAiSdkToolOutput)
packages/apex/src/core/ai/providers/pensarFormatters.ts:128
packages/apex/src/core/agents/offSecAgent/trace.ts:285-291
No new event types, no new fields, no new flags. Same shape the SDK itself produces when a tool's execute() throws.
Local TUI / session persistence
messagesPath is written from latestMessages (offensiveSecurityAgent.ts:352-356, 381-388), which mirrors the SDK's response.messages. Synthetic bus emissions alone won't make it into that file — meaning a resumed session or TUI replay would still be missing the aborted tools' results.
To keep local persistence consistent: in the same catch/finally block that emits the synthetic bus events, also append corresponding tool-result content parts to latestMessages so the next writeFile picks them up. The shape going into latestMessages should match what the SDK would have produced — a { role: 'tool', content: [{ type: 'tool-result', toolCallId, toolName, output: { type: 'error-text', value: '...' } }] } message. Then unwrapAiSdkToolOutput handles it correctly on resume just like a real tool error.
In-flight tracking — keep it local
The tracking Map lives inside consume(). No new abstractions, no new class members beyond what's needed. When per-call lifecycle becomes a first-class concept in future restructuring, this inline state goes away cleanly.
Out of scope
- Run-level abort propagation (the workflow catching the throw and marking the run terminal) — already handled by callers.
- Any new bus event types or new role values — explicit non-goal, the existing
tool-result event and SDK envelope are sufficient.
When
OffensiveSecurityAgent.consume()(packages/apex/src/core/agents/offSecAgent/offensiveSecurityAgent.ts:449-458) is iteratingstreamResult.fullStreamand the iterator throws — AbortSignal fired (endpoint timer, scan-level timer, user abort), idle-stream timeout, or a transport error — any tool whosetool-call-completewas emitted but whosetool-resulthad not yet streamed is silently dropped. The bus emits nothing for it on the way out. Downstream in-process consumers (TUI rendering, session persistence on disk, and any external subscribers attached to a parent bus) are left with a tool-call that has no matching result.Proposal
Track in-flight tool calls inside
consume()(sawtool-call-completefor a toolCallId but not yettool-result). On stream abort/exception, emit synthetictool-resultevents for each, reusing the existing AI SDK output envelope:error-text(and its siblingerror-json) is already a first-class variant in the SDK's tool-output discriminated union (packages/apex/src/core/messages/types.ts:61-77) and is already handled across the codebase:packages/apex/src/core/session/persistence.ts:419(unwrapAiSdkToolOutput)packages/apex/src/core/ai/providers/pensarFormatters.ts:128packages/apex/src/core/agents/offSecAgent/trace.ts:285-291No new event types, no new fields, no new flags. Same shape the SDK itself produces when a tool's
execute()throws.Local TUI / session persistence
messagesPathis written fromlatestMessages(offensiveSecurityAgent.ts:352-356, 381-388), which mirrors the SDK'sresponse.messages. Synthetic bus emissions alone won't make it into that file — meaning a resumed session or TUI replay would still be missing the aborted tools' results.To keep local persistence consistent: in the same
catch/finallyblock that emits the synthetic bus events, also append correspondingtool-resultcontent parts tolatestMessagesso the nextwriteFilepicks them up. The shape going intolatestMessagesshould match what the SDK would have produced — a{ role: 'tool', content: [{ type: 'tool-result', toolCallId, toolName, output: { type: 'error-text', value: '...' } }] }message. ThenunwrapAiSdkToolOutputhandles it correctly on resume just like a real tool error.In-flight tracking — keep it local
The tracking Map lives inside
consume(). No new abstractions, no new class members beyond what's needed. When per-call lifecycle becomes a first-class concept in future restructuring, this inline state goes away cleanly.Out of scope
tool-resultevent and SDK envelope are sufficient.