fix(coding-agent): finalize session stop lifecycle
This commit is contained in:
@@ -638,7 +638,10 @@ export class ExtensionRunner {
|
||||
|
||||
if (event.type === "session_stop" && handlerResult) {
|
||||
result = handlerResult as SessionStopEventResult;
|
||||
if (result.continue === true || result.decision === "block") {
|
||||
const hasContinuationContext =
|
||||
(typeof result.additionalContext === "string" && result.additionalContext.length > 0) ||
|
||||
(typeof result.reason === "string" && result.reason.length > 0);
|
||||
if ((result.continue === true || result.decision === "block") && hasContinuationContext) {
|
||||
return result as RunnerEmitResult<TEvent>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2499,6 +2499,9 @@ export class AgentSession {
|
||||
|
||||
// Check auto-retry and auto-compaction after agent completes
|
||||
if (event.type === "agent_end") {
|
||||
const emitAgentEndNotification = async () => {
|
||||
await this.#emitAgentEndNotification(event.messages);
|
||||
};
|
||||
const usage = this.getSessionStats().tokens;
|
||||
await this.#goalRuntime.onAgentEnd({
|
||||
currentUsage: {
|
||||
@@ -2515,6 +2518,7 @@ export class AgentSession {
|
||||
this.#lastAssistantMessage = undefined;
|
||||
if (!msg) {
|
||||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2531,36 +2535,48 @@ export class AgentSession {
|
||||
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
|
||||
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
||||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#assistantEndedWithSuccessfulYield(msg)) {
|
||||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
this.#lastSuccessfulYieldToolCallId = undefined;
|
||||
|
||||
if (await this.#handleEmptyAssistantStop(msg)) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
if (await this.#handleUnexpectedAssistantStop(msg)) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isRetryableReasonlessAbort(msg)) {
|
||||
const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
|
||||
if (didRetry) return;
|
||||
if (didRetry) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A deliberate abort should settle the current turn, not trigger queued continuations.
|
||||
if (msg.stopReason === "aborted") {
|
||||
this.#resolveRetry();
|
||||
this.#resetSessionStopContinuationState();
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
// Check for retryable errors first (overloaded, rate limit, server errors)
|
||||
if (this.#isRetryableError(msg)) {
|
||||
const didRetry = await this.#handleRetryableError(msg);
|
||||
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
||||
if (didRetry) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.#resolveRetry();
|
||||
|
||||
@@ -2570,6 +2586,7 @@ export class AgentSession {
|
||||
// Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
|
||||
const hasToolCalls = msg.content.some(content => content.type === "toolCall");
|
||||
if (hasToolCalls) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
// When checkCompaction scheduled a deferred handoff, skip the rewind/todo passes:
|
||||
@@ -2577,14 +2594,22 @@ export class AgentSession {
|
||||
// #scheduleAgentContinue would start a fresh streaming turn alongside the handoff
|
||||
// LLM call (visible as "Auto-handoff" loader + an assistant message still streaming).
|
||||
if (compactionDeferredHandoff) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
if (msg.stopReason !== "error") {
|
||||
if (this.#enforceRewindBeforeYield()) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
const todoContinuationScheduled = await this.#checkTodoCompletion();
|
||||
if (todoContinuationScheduled) {
|
||||
await emitAgentEndNotification();
|
||||
return;
|
||||
}
|
||||
await this.#checkTodoCompletion();
|
||||
}
|
||||
await this.#emitSessionStopEvent(event.messages);
|
||||
await emitAgentEndNotification();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3540,6 +3565,11 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
#resetSessionStopContinuationState(): void {
|
||||
this.#sessionStopContinuationCount = 0;
|
||||
this.#sessionStopHookActive = false;
|
||||
}
|
||||
|
||||
#sessionStopContinuationContext(result: SessionStopEventResult | undefined): string | undefined {
|
||||
if (!result) return undefined;
|
||||
if (result.continue === true) {
|
||||
@@ -3551,11 +3581,15 @@ export class AgentSession {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async #emitAgentEndNotification(messages: AgentMessage[]): Promise<void> {
|
||||
await this.#extensionRunner?.emit({ type: "agent_end", messages });
|
||||
}
|
||||
|
||||
async #emitSessionStopEvent(messages: AgentMessage[]): Promise<void> {
|
||||
if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) return;
|
||||
const result = await this.#extensionRunner.emitSessionStop({
|
||||
messages,
|
||||
turn_id: this.#turnIndex,
|
||||
turn_id: Math.max(0, this.#turnIndex - 1),
|
||||
last_assistant_message: this.getLastAssistantMessage(),
|
||||
session_id: this.sessionId,
|
||||
session_file: this.sessionFile,
|
||||
@@ -3563,8 +3597,7 @@ export class AgentSession {
|
||||
});
|
||||
const additionalContext = this.#sessionStopContinuationContext(result);
|
||||
if (!additionalContext) {
|
||||
this.#sessionStopContinuationCount = 0;
|
||||
this.#sessionStopHookActive = false;
|
||||
this.#resetSessionStopContinuationState();
|
||||
return;
|
||||
}
|
||||
if (this.#sessionStopContinuationCount >= SESSION_STOP_CONTINUATION_CAP) {
|
||||
@@ -3572,20 +3605,21 @@ export class AgentSession {
|
||||
sessionId: this.sessionId,
|
||||
cap: SESSION_STOP_CONTINUATION_CAP,
|
||||
});
|
||||
this.#sessionStopContinuationCount = 0;
|
||||
this.#sessionStopHookActive = false;
|
||||
this.#resetSessionStopContinuationState();
|
||||
return;
|
||||
}
|
||||
this.#sessionStopContinuationCount++;
|
||||
this.#sessionStopHookActive = true;
|
||||
await this.sendCustomMessage(
|
||||
this.#queueHiddenNextTurnMessage(
|
||||
{
|
||||
role: "custom",
|
||||
customType: "session-stop-continuation",
|
||||
content: additionalContext,
|
||||
display: false,
|
||||
attribution: "agent",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ deliverAs: "nextTurn", triggerTurn: true },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3596,8 +3630,9 @@ export class AgentSession {
|
||||
this.#turnIndex = 0;
|
||||
await this.#extensionRunner.emit({ type: "agent_start" });
|
||||
} else if (event.type === "agent_end") {
|
||||
await this.#extensionRunner.emit({ type: "agent_end", messages: event.messages });
|
||||
await this.#emitSessionStopEvent(event.messages);
|
||||
// `agent_end` extension notification is emitted from the settled
|
||||
// agent_end maintenance path so `session_stop` control hooks are not
|
||||
// blocked by unrelated notification-only work.
|
||||
} else if (event.type === "turn_start") {
|
||||
const hookEvent: TurnStartEvent = {
|
||||
type: "turn_start",
|
||||
@@ -6027,6 +6062,16 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async #promptAgentInitiatedMessage(message: CustomMessage): Promise<void> {
|
||||
this.#beginInFlight();
|
||||
try {
|
||||
await this.agent.prompt(message);
|
||||
await this.#waitForPostPromptRecovery();
|
||||
} finally {
|
||||
this.#endInFlight();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a custom message to the session. Creates a CustomMessageEntry.
|
||||
*
|
||||
@@ -6086,7 +6131,7 @@ export class AgentSession {
|
||||
this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
|
||||
return false;
|
||||
}
|
||||
await this.agent.prompt(normalizedAppMessage);
|
||||
await this.#promptAgentInitiatedMessage(normalizedAppMessage);
|
||||
return true;
|
||||
}
|
||||
this.agent.appendMessage(normalizedAppMessage);
|
||||
@@ -6105,7 +6150,7 @@ export class AgentSession {
|
||||
this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
|
||||
return false;
|
||||
}
|
||||
await this.agent.prompt(normalizedAppMessage);
|
||||
await this.#promptAgentInitiatedMessage(normalizedAppMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8224,12 +8269,12 @@ export class AgentSession {
|
||||
/**
|
||||
* Check if agent stopped with incomplete todos and prompt to continue.
|
||||
*/
|
||||
async #checkTodoCompletion(): Promise<void> {
|
||||
async #checkTodoCompletion(): Promise<boolean> {
|
||||
// Skip todo reminders when the most recent turn was driven by an explicit user force —
|
||||
// the user wanted exactly that tool, not a follow-up nag about incomplete todos.
|
||||
const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
|
||||
if (lastServedLabel === "user-force") {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Suppress within a self-continuation chain: if the agent's last turn was driven by a
|
||||
@@ -8240,7 +8285,7 @@ export class AgentSession {
|
||||
logger.debug("Todo completion: prior reminder still awaiting agent action; staying silent", {
|
||||
attempt: this.#todoReminderCount,
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const remindersEnabled = this.settings.get("todo.reminders");
|
||||
@@ -8248,20 +8293,20 @@ export class AgentSession {
|
||||
if (!remindersEnabled || !todosEnabled) {
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const remindersMax = this.settings.get("todo.reminders.max");
|
||||
if (this.#todoReminderCount >= remindersMax) {
|
||||
logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const phases = this.getTodoPhases();
|
||||
if (phases.length === 0) {
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const incompleteByPhase = phases
|
||||
@@ -8279,7 +8324,7 @@ export class AgentSession {
|
||||
if (incomplete.length === 0) {
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build reminder message
|
||||
@@ -8319,6 +8364,7 @@ export class AgentSession {
|
||||
this.agent.appendMessage(reminderMessage);
|
||||
this.sessionManager.appendMessage(reminderMessage);
|
||||
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -266,13 +266,19 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
const stopEvents: Array<{
|
||||
stop_hook_active: boolean;
|
||||
session_id: string;
|
||||
turn_id: number;
|
||||
last_assistant_message?: AgentMessage;
|
||||
}> = [];
|
||||
const eventOrder: string[] = [];
|
||||
const extensionRunner = {
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
emit: vi.fn(event => {
|
||||
eventOrder.push(event.type);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
emitBeforeAgentStart: vi.fn().mockResolvedValue(undefined),
|
||||
hasHandlers: vi.fn((eventType: string) => eventType === "session_stop"),
|
||||
emitSessionStop: vi.fn(event => {
|
||||
eventOrder.push("session_stop");
|
||||
stopEvents.push(event);
|
||||
if (stopEvents.length === 1) {
|
||||
return Promise.resolve({ continue: true, additionalContext: "Mission incomplete; continue." });
|
||||
@@ -303,7 +309,14 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(eventOrder.filter(type => type === "session_stop" || type === "agent_end")).toEqual([
|
||||
"session_stop",
|
||||
"agent_end",
|
||||
"session_stop",
|
||||
"agent_end",
|
||||
]);
|
||||
expect(stopEvents.map(event => event.stop_hook_active)).toEqual([false, true]);
|
||||
expect(stopEvents.map(event => event.turn_id)).toEqual([0, 0]);
|
||||
expect(stopEvents[0]?.session_id).toBe(session.sessionId);
|
||||
expect(stopEvents[0]?.last_assistant_message?.role).toBe("assistant");
|
||||
});
|
||||
@@ -341,6 +354,91 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
expect(extensionRunner.emitSessionStop).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
it("emits session_stop only after empty-stop recovery reaches a final stop", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const mock = createMockModel({
|
||||
responses: [{ content: [""] }, { content: ["Recovered"] }],
|
||||
});
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: ["Test"], tools: [] },
|
||||
streamFn: mock.stream,
|
||||
convertToLlm,
|
||||
});
|
||||
const extensionRunner = {
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
emitBeforeAgentStart: vi.fn().mockResolvedValue(undefined),
|
||||
hasHandlers: vi.fn((eventType: string) => eventType === "session_stop"),
|
||||
emitSessionStop: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ExtensionRunner;
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settings = Settings.isolated();
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorages.push(authStorage);
|
||||
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({ agent, sessionManager, settings, modelRegistry, extensionRunner });
|
||||
|
||||
await session.prompt("First message");
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(mock.calls).toHaveLength(2);
|
||||
expect(extensionRunner.emitSessionStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues session_stop feedback in ACP sessions with deferred client turns", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const mock = createMockModel({
|
||||
handler: () => ({ content: ["Done"] }),
|
||||
});
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: ["Test"], tools: [] },
|
||||
streamFn: mock.stream,
|
||||
convertToLlm,
|
||||
});
|
||||
let stopCount = 0;
|
||||
const extensionRunner = {
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
emitBeforeAgentStart: vi.fn().mockResolvedValue(undefined),
|
||||
hasHandlers: vi.fn((eventType: string) => eventType === "session_stop"),
|
||||
emitSessionStop: vi.fn(() => {
|
||||
stopCount++;
|
||||
if (stopCount === 1) {
|
||||
return Promise.resolve({ continue: true, additionalContext: "ACP stop continuation." });
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
} as unknown as ExtensionRunner;
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settings = Settings.isolated();
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorages.push(authStorage);
|
||||
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
session = new AgentSession({ agent, sessionManager, settings, modelRegistry, extensionRunner });
|
||||
session.setClientBridge({
|
||||
capabilities: {},
|
||||
deferAgentInitiatedTurns: true,
|
||||
});
|
||||
|
||||
await session.prompt("First message");
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(mock.calls).toHaveLength(2);
|
||||
expect(
|
||||
mock.calls[1]?.context.messages.some(message =>
|
||||
typeof message.content === "string"
|
||||
? message.content.includes("ACP stop continuation.")
|
||||
: message.content.some(
|
||||
content => content.type === "text" && content.text.includes("ACP stop continuation."),
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit session_stop for subagent sessions", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const mock = createMockModel({
|
||||
|
||||
@@ -628,7 +628,7 @@ describe("ExtensionRunner", () => {
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "session-stop.ts"), extCode);
|
||||
await Bun.write(path.join(extensionsDir, "session-stop.ts"), extCode);
|
||||
|
||||
const result = await loadTestExtensions();
|
||||
const runner = new ExtensionRunner(
|
||||
@@ -665,8 +665,7 @@ describe("ExtensionRunner", () => {
|
||||
stop_hook_active: false,
|
||||
});
|
||||
|
||||
const events = fs
|
||||
.readFileSync(eventsPath, "utf8")
|
||||
const events = (await Bun.file(eventsPath).text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(line => JSON.parse(line));
|
||||
@@ -683,6 +682,55 @@ describe("ExtensionRunner", () => {
|
||||
]);
|
||||
expect(stopResult).toEqual({ continue: true, additionalContext: "Run one more pass." });
|
||||
});
|
||||
|
||||
it("continues to later handlers after empty continuation feedback", async () => {
|
||||
await Bun.write(
|
||||
path.join(extensionsDir, "session-stop-empty.ts"),
|
||||
`
|
||||
export default function(pi) {
|
||||
pi.on("session_stop", async () => ({ continue: true }));
|
||||
pi.on("session_stop", async () => ({ decision: "block", reason: "Continue from second handler." }));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await loadTestExtensions();
|
||||
const runner = new ExtensionRunner(
|
||||
result.extensions,
|
||||
result.runtime,
|
||||
tempDir.path(),
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
const completedMessage: AgentMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "main session finished" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 123,
|
||||
};
|
||||
|
||||
await expect(
|
||||
runner.emitSessionStop({
|
||||
messages: [completedMessage],
|
||||
turn_id: 0,
|
||||
last_assistant_message: completedMessage,
|
||||
session_id: "session-123",
|
||||
session_file: "/tmp/session.jsonl",
|
||||
stop_hook_active: false,
|
||||
}),
|
||||
).resolves.toEqual({ decision: "block", reason: "Continue from second handler." });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool_result chaining", () => {
|
||||
|
||||
Reference in New Issue
Block a user