Skip to content

Commit 0a08625

Browse files
committed
fix(agents): emit terminal abort lifecycle metadata
Carry terminal abort state into embedded agent lifecycle events before agent_end emits, and include terminal stopReason from the last assistant message when runner metadata is not available yet. Fixes #66534
1 parent 74331f6 commit 0a08625

10 files changed

Lines changed: 118 additions & 10 deletions

src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ describe("overflow compaction in run loop", () => {
631631
livenessState: "abandoned",
632632
timeoutPhase: "provider",
633633
providerStarted: true,
634+
aborted: true,
634635
});
635636
});
636637

src/agents/embedded-agent-runner/run.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,11 @@ export async function runEmbeddedAgent(
17241724
lastAssistant: sessionLastAssistant,
17251725
currentAttemptAssistant,
17261726
} = attempt;
1727+
const setTerminalLifecycleMeta: NonNullable<
1728+
typeof attempt.setTerminalLifecycleMeta
1729+
> = (meta) => {
1730+
attempt.setTerminalLifecycleMeta?.({ ...meta, aborted });
1731+
};
17271732
const timedOutDuringToolExecution = attempt.timedOutDuringToolExecution ?? false;
17281733
if (sessionIdUsed && sessionIdUsed !== activeSessionId) {
17291734
activeSessionId = sessionIdUsed;
@@ -2340,7 +2345,7 @@ export async function runEmbeddedAgent(
23402345
`[context-overflow-recovery] exhausted provider overflow recovery for ${provider}/${modelId}; ` +
23412346
`livenessState=blocked suggestedAction=reset_or_new kind=${kind}`,
23422347
);
2343-
attempt.setTerminalLifecycleMeta?.({
2348+
setTerminalLifecycleMeta({
23442349
replayInvalid: resolveReplayInvalidForAttempt(),
23452350
livenessState: "blocked",
23462351
});
@@ -2378,7 +2383,7 @@ export async function runEmbeddedAgent(
23782383
if (promptErrorSource === "hook:before_agent_run" && !aborted) {
23792384
const errorText = formatErrorMessage(promptError);
23802385
const replayInvalid = resolveReplayInvalidForAttempt();
2381-
attempt.setTerminalLifecycleMeta?.({
2386+
setTerminalLifecycleMeta({
23822387
replayInvalid,
23832388
livenessState: "blocked",
23842389
});
@@ -2483,7 +2488,7 @@ export async function runEmbeddedAgent(
24832488
}
24842489
// Handle role ordering errors with a user-friendly message
24852490
if (/incorrect role information|roles must alternate/i.test(errorText)) {
2486-
attempt.setTerminalLifecycleMeta?.({
2491+
setTerminalLifecycleMeta({
24872492
replayInvalid: resolveReplayInvalidForAttempt(),
24882493
livenessState: "blocked",
24892494
});
@@ -2524,7 +2529,7 @@ export async function runEmbeddedAgent(
25242529
const maxMbLabel =
25252530
typeof maxMb === "number" && Number.isFinite(maxMb) ? `${maxMb}` : null;
25262531
const maxBytesHint = maxMbLabel ? ` (max ${maxMbLabel}MB)` : "";
2527-
attempt.setTerminalLifecycleMeta?.({
2532+
setTerminalLifecycleMeta({
25282533
replayInvalid: resolveReplayInvalidForAttempt(),
25292534
livenessState: "blocked",
25302535
});
@@ -3035,7 +3040,7 @@ export async function runEmbeddedAgent(
30353040
});
30363041
const timeoutPhase = attempt.promptTimeoutOutcome?.timeoutPhase ?? "provider";
30373042
const providerStarted = attempt.promptTimeoutOutcome?.providerStarted ?? true;
3038-
attempt.setTerminalLifecycleMeta?.({
3043+
setTerminalLifecycleMeta({
30393044
replayInvalid,
30403045
livenessState,
30413046
timeoutPhase,
@@ -3287,7 +3292,7 @@ export async function runEmbeddedAgent(
32873292
// terminal.
32883293
const replayInvalid = resolveReplayInvalidForAttempt(null);
32893294
const livenessState: EmbeddedRunLivenessState = "blocked";
3290-
attempt.setTerminalLifecycleMeta?.({
3295+
setTerminalLifecycleMeta({
32913296
replayInvalid,
32923297
livenessState,
32933298
});
@@ -3336,7 +3341,7 @@ export async function runEmbeddedAgent(
33363341
attempt,
33373342
incompleteTurnText: "⚠️ Agent couldn't generate a response. Please try again.",
33383343
});
3339-
attempt.setTerminalLifecycleMeta?.({
3344+
setTerminalLifecycleMeta({
33403345
replayInvalid,
33413346
livenessState,
33423347
});
@@ -3442,7 +3447,7 @@ export async function runEmbeddedAgent(
34423447
attempt,
34433448
incompleteTurnText,
34443449
});
3445-
attempt.setTerminalLifecycleMeta?.({
3450+
setTerminalLifecycleMeta({
34463451
replayInvalid,
34473452
livenessState,
34483453
});
@@ -3564,7 +3569,7 @@ export async function runEmbeddedAgent(
35643569
const terminalPayloads = emptyAssistantReplyIsSilent
35653570
? [{ text: SILENT_REPLY_TOKEN }]
35663571
: payloadsForTerminalPath;
3567-
attempt.setTerminalLifecycleMeta?.({
3572+
setTerminalLifecycleMeta({
35683573
replayInvalid,
35693574
livenessState,
35703575
stopReason,

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3226,6 +3226,7 @@ export async function runEmbeddedAttempt(
32263226
onExecutionPhase: params.onExecutionPhase,
32273227
onAgentEvent: params.onAgentEvent,
32283228
terminalLifecyclePhase: params.deferTerminalLifecycleEnd ? "finishing" : "end",
3229+
isTerminalAborted: () => aborted,
32293230
onBeforeLifecycleTerminal: () => {
32303231
if (
32313232
requiresCompletionRequiredAsyncTaskWait({

src/agents/embedded-agent-runner/run/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,5 +216,6 @@ export type EmbeddedRunAttemptResult = {
216216
yielded?: boolean;
217217
timeoutPhase?: AgentRunTimeoutPhase;
218218
providerStarted?: boolean;
219+
aborted?: boolean;
219220
}) => void;
220221
};

src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,52 @@ describe("handleAgentEnd", () => {
214214
});
215215
});
216216

217+
it("emits explicit aborted terminal metadata on lifecycle end events", async () => {
218+
emitAgentEventMock.mockClear();
219+
const onAgentEvent = vi.fn();
220+
const ctx = createContext(undefined, { onAgentEvent });
221+
ctx.state.terminalStopReason = "end_turn";
222+
ctx.state.terminalAborted = true;
223+
224+
await handleAgentEnd(ctx);
225+
226+
expect(emitAgentEventMock).toHaveBeenCalledWith({
227+
runId: "run-1",
228+
stream: "lifecycle",
229+
data: expect.objectContaining({
230+
phase: "end",
231+
stopReason: "end_turn",
232+
aborted: true,
233+
}),
234+
});
235+
expect(onAgentEvent).toHaveBeenCalledWith({
236+
stream: "lifecycle",
237+
data: {
238+
phase: "end",
239+
stopReason: "end_turn",
240+
aborted: true,
241+
},
242+
});
243+
});
244+
245+
it("keeps normal lifecycle end events explicitly non-aborted", async () => {
246+
const onAgentEvent = vi.fn();
247+
const ctx = createContext(undefined, { onAgentEvent });
248+
ctx.state.terminalStopReason = "end_turn";
249+
ctx.state.terminalAborted = false;
250+
251+
await handleAgentEnd(ctx);
252+
253+
expect(onAgentEvent).toHaveBeenCalledWith({
254+
stream: "lifecycle",
255+
data: {
256+
phase: "end",
257+
stopReason: "end_turn",
258+
aborted: false,
259+
},
260+
});
261+
});
262+
217263
it("attaches raw provider error metadata and includes model/provider in console output", async () => {
218264
const ctx = createContext({
219265
role: "assistant",
@@ -413,6 +459,7 @@ describe("handleAgentEnd", () => {
413459
stream: "lifecycle",
414460
data: {
415461
phase: "end",
462+
stopReason: "toolUse",
416463
livenessState: "abandoned",
417464
replayInvalid: true,
418465
},
@@ -441,6 +488,7 @@ describe("handleAgentEnd", () => {
441488
stream: "lifecycle",
442489
data: {
443490
phase: "end",
491+
stopReason: "toolUse",
444492
livenessState: "abandoned",
445493
replayInvalid: true,
446494
},

src/agents/embedded-agent-subscribe.handlers.lifecycle.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,21 @@ export function handleAgentEnd(
130130
}
131131

132132
const emitLifecycleTerminal = () => {
133+
const terminalStopReason =
134+
ctx.state.terminalStopReason ??
135+
(!isError && isAssistantMessage(lastAssistant) ? lastAssistant.stopReason : undefined);
136+
const terminalAborted =
137+
typeof ctx.state.terminalAborted === "boolean"
138+
? ctx.state.terminalAborted
139+
: ctx.params.isTerminalAborted?.();
133140
const terminalMeta = {
134-
...(ctx.state.terminalStopReason ? { stopReason: ctx.state.terminalStopReason } : {}),
141+
...(terminalStopReason ? { stopReason: terminalStopReason } : {}),
135142
...(ctx.state.yielded === true ? { yielded: true } : {}),
136143
...(ctx.state.timeoutPhase ? { timeoutPhase: ctx.state.timeoutPhase } : {}),
137144
...(typeof ctx.state.providerStarted === "boolean"
138145
? { providerStarted: ctx.state.providerStarted }
139146
: {}),
147+
...(typeof terminalAborted === "boolean" ? { aborted: terminalAborted } : {}),
140148
};
141149
if (isError) {
142150
emitAgentEvent({

src/agents/embedded-agent-subscribe.handlers.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export type EmbeddedAgentSubscribeState = {
151151
yielded?: boolean;
152152
timeoutPhase?: AgentRunTimeoutPhase;
153153
providerStarted?: boolean;
154+
terminalAborted?: boolean;
154155
hadDeterministicSideEffect?: boolean;
155156
pendingEventChain: Promise<void> | null;
156157

src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,43 @@ describe("subscribeEmbeddedAgentSession", () => {
12851285
expect(error).toContain("API rate limit reached");
12861286
});
12871287

1288+
it("reads terminal abort state before emitting lifecycle:end", () => {
1289+
const { session, emit } = createStubSessionHarness();
1290+
const onAgentEvent = vi.fn();
1291+
let terminalAborted = false;
1292+
subscribeEmbeddedAgentSession({
1293+
session,
1294+
runId: "run-aborted",
1295+
sessionKey: "test-session",
1296+
onAgentEvent,
1297+
isTerminalAborted: () => terminalAborted,
1298+
});
1299+
const assistantMessage = {
1300+
api: "test",
1301+
provider: "test",
1302+
model: "test",
1303+
role: "assistant",
1304+
stopReason: "aborted",
1305+
content: [],
1306+
usage: makeZeroUsageSnapshot(),
1307+
timestamp: 0,
1308+
} as AssistantMessage;
1309+
1310+
emit({ type: "message_start", message: assistantMessage });
1311+
emit({ type: "message_end", message: assistantMessage });
1312+
terminalAborted = true;
1313+
emit({ type: "agent_end", messages: [assistantMessage] });
1314+
1315+
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
1316+
expect(payloads).toContainEqual(
1317+
expect.objectContaining({
1318+
phase: "end",
1319+
stopReason: "aborted",
1320+
aborted: true,
1321+
}),
1322+
);
1323+
});
1324+
12881325
it("preserves replay-invalid lifecycle truth across compaction retries after mutating tools", () => {
12891326
const { session, emit } = createStubSessionHarness();
12901327
const onAgentEvent = vi.fn();

src/agents/embedded-agent-subscribe.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
13461346
yielded?: boolean;
13471347
timeoutPhase?: AgentRunTimeoutPhase;
13481348
providerStarted?: boolean;
1349+
aborted?: boolean;
13491350
}) => {
13501351
if (typeof meta.replayInvalid === "boolean") {
13511352
state.replayState = { ...state.replayState, replayInvalid: meta.replayInvalid };
@@ -1365,6 +1366,9 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
13651366
if (typeof meta.providerStarted === "boolean") {
13661367
state.providerStarted = meta.providerStarted;
13671368
}
1369+
if (typeof meta.aborted === "boolean") {
1370+
state.terminalAborted = meta.aborted;
1371+
}
13681372
},
13691373
isCompacting: () => state.compactionInFlight || state.pendingCompactionRetry > 0,
13701374
isCompactionInFlight: () => state.compactionInFlight,

src/agents/embedded-agent-subscribe.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export type SubscribeEmbeddedAgentSessionParams = {
6969
}) => void | Promise<void>;
7070
onHeartbeatToolResponse?: (response: HeartbeatToolResponse) => void | Promise<void>;
7171
terminalLifecyclePhase?: "end" | "finishing";
72+
/** Read immediately before terminal lifecycle emission. */
73+
isTerminalAborted?: () => boolean | undefined;
7274
/** Gate final block delivery/lifecycle after the natural answer is known. */
7375
onBeforeTerminalDelivery?: (event: {
7476
messages: AgentMessage[];

0 commit comments

Comments
 (0)