Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 97 additions & 97 deletions packages/agent-sdk/src/managers/aiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1271,7 +1271,103 @@ export class AIManager {
// Set loading to false first
this.setIsLoading(false);

// Inject pending notifications from background tasks
// Clear temporary rules
this.permissionManager?.clearTemporaryRules();

// Clear abort controllers
this.abortController = null;
this.toolAbortController = null;

// Execute Stop/SubagentStop hooks only if the operation was not aborted
const isCurrentlyAborted =
abortController.signal.aborted || toolAbortController.signal.aborted;

if (!isCurrentlyAborted) {
// Record committed snapshots to message history for the final turn
if (this.reversionManager) {
const snapshots =
this.reversionManager.getAndClearCommittedSnapshots();
if (snapshots.length > 0) {
this.messageManager.addFileHistoryBlock(snapshots);
}
}

// Goal evaluation — supersedes Stop hooks when active
const goalManager = this.container.has("GoalManager")
? this.container.get<import("./goalManager.js").GoalManager>(
"GoalManager",
)
: undefined;

let goalContinuing = false;

if (goalManager?.isGoalActive() && !this.subagentType) {
// 1. Increment turn count and check circuit breakers
goalManager.incrementTurnCount();
const circuitBreaker = goalManager.checkCircuitBreakers();

if (circuitBreaker) {
goalManager.clearGoal();
logger?.info(`[Goal] ${circuitBreaker}`);
this.messageManager.addUserMessage({
content: `<system-reminder>${circuitBreaker}</system-reminder>`,
isMeta: true,
});
// Fall through to normal Stop hooks on the final turn
} else {
// 2. Evaluate goal
const evaluation = await goalManager.evaluateGoal(
abortController.signal,
);

if (evaluation.isMet) {
goalManager.clearGoal();
logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
this.messageManager.addUserMessage({
content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
isMeta: true,
});
// Fall through to normal Stop hooks on the final turn
} else {
const goal = goalManager.getGoal()!;
goal.lastReason = evaluation.reason;
logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
this.messageManager.addUserMessage({
content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
isMeta: true,
});
// Keep loading state active to prevent UI flicker
this.setIsLoading(true);
goalContinuing = true;
// Restart outer loop to continue goal pursuit
shouldRestart = true;
turnOffset = 0;
}
}
}

// Skip Stop hooks when goal evaluator is continuing the conversation
if (goalContinuing) {
// Goal evaluator supersedes Stop hooks
} else {
const shouldContinue = await this.executeStopHooks();

// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
// restart the AI conversation cycle
if (shouldContinue) {
logger?.info(
`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
);

// Restart the conversation to let AI fix the issues
shouldRestart = true;
turnOffset = 0;
}
}
}

// Inject pending notifications from background tasks (after Stop hooks,
// aligned with Claude Code which fires Stop hooks unconditionally)
const notificationQueue = this.container.has("NotificationQueue")
? this.container.get<NotificationQueue>("NotificationQueue")
: undefined;
Expand All @@ -1292,102 +1388,6 @@ export class AIManager {
// Restart outer loop to process the notifications
shouldRestart = true;
turnOffset = 0;
} else {
// Clear temporary rules
this.permissionManager?.clearTemporaryRules();

// Clear abort controllers
this.abortController = null;
this.toolAbortController = null;

// Execute Stop/SubagentStop hooks only if the operation was not aborted
const isCurrentlyAborted =
abortController.signal.aborted ||
toolAbortController.signal.aborted;

if (!isCurrentlyAborted) {
// Record committed snapshots to message history for the final turn
if (this.reversionManager) {
const snapshots =
this.reversionManager.getAndClearCommittedSnapshots();
if (snapshots.length > 0) {
this.messageManager.addFileHistoryBlock(snapshots);
}
}

// Goal evaluation — supersedes Stop hooks when active
const goalManager = this.container.has("GoalManager")
? this.container.get<import("./goalManager.js").GoalManager>(
"GoalManager",
)
: undefined;

let goalContinuing = false;

if (goalManager?.isGoalActive() && !this.subagentType) {
// 1. Increment turn count and check circuit breakers
goalManager.incrementTurnCount();
const circuitBreaker = goalManager.checkCircuitBreakers();

if (circuitBreaker) {
goalManager.clearGoal();
logger?.info(`[Goal] ${circuitBreaker}`);
this.messageManager.addUserMessage({
content: `<system-reminder>${circuitBreaker}</system-reminder>`,
isMeta: true,
});
// Fall through to normal Stop hooks on the final turn
} else {
// 2. Evaluate goal
const evaluation = await goalManager.evaluateGoal(
abortController.signal,
);

if (evaluation.isMet) {
goalManager.clearGoal();
logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
this.messageManager.addUserMessage({
content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
isMeta: true,
});
// Fall through to normal Stop hooks on the final turn
} else {
const goal = goalManager.getGoal()!;
goal.lastReason = evaluation.reason;
logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
this.messageManager.addUserMessage({
content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
isMeta: true,
});
// Keep loading state active to prevent UI flicker
this.setIsLoading(true);
goalContinuing = true;
// Restart outer loop to continue goal pursuit
shouldRestart = true;
turnOffset = 0;
}
}
}

// Skip Stop hooks when goal evaluator is continuing the conversation
if (goalContinuing) {
// Goal evaluator supersedes Stop hooks
} else {
const shouldContinue = await this.executeStopHooks();

// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
// restart the AI conversation cycle
if (shouldContinue) {
logger?.info(
`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
);

// Restart the conversation to let AI fix the issues
shouldRestart = true;
turnOffset = 0;
}
}
}
}
}

Expand Down
80 changes: 80 additions & 0 deletions packages/agent-sdk/tests/managers/aiManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,86 @@ describe("AIManager", () => {
await testAIManager.sendAIMessage();
expect(mockNotificationQueue.dequeueAll).toHaveBeenCalled();
});

it("should execute Stop hooks even when notifications are pending", async () => {
const taskManager = {
on: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
} as unknown as TaskManager;

// First call returns true (pending), second call returns false (after dequeue)
const mockNotificationQueue = {
hasPending: vi
.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false),
dequeueAll: vi
.fn()
.mockReturnValue([
"<task-notification><task-id>test</task-id></task-notification>",
]),
};

const container = new Container();
container.register("ConfigurationService", {
resolveGatewayConfig: vi.fn().mockReturnValue(mockGatewayConfig),
resolveModelConfig: vi.fn().mockReturnValue(mockModelConfig),
resolveMaxInputTokens: vi.fn().mockReturnValue(96000),
resolveAutoMemoryEnabled: vi.fn().mockReturnValue(true),
resolveLanguage: vi.fn().mockReturnValue(undefined),
});
container.register("MessageManager", mockMessageManager);
container.register("ToolManager", mockToolManager);
container.register("TaskManager", taskManager);
container.register("MemoryService", {
getCombinedMemoryContent: vi.fn().mockResolvedValue(""),
getAutoMemoryDirectory: vi.fn().mockReturnValue("/mock/auto-memory"),
ensureAutoMemoryDirectory: vi.fn().mockResolvedValue(undefined),
getAutoMemoryContent: vi.fn().mockResolvedValue(""),
});
container.register("PermissionManager", {
getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"),
clearTemporaryRules: vi.fn(),
getPlanFilePath: vi.fn().mockReturnValue(undefined),
setHasExitedPlanMode: vi.fn(),
hasExitedPlanModeInSession: vi.fn(() => false),
setNeedsPlanModeExitAttachment: vi.fn(),
getNeedsPlanModeExitAttachment: vi.fn(() => false),
} as unknown as Record<string, unknown>);
container.register("SubagentManager", {
getConfigurations: vi.fn().mockReturnValue([]),
});
container.register("SkillManager", {
getAvailableSkills: vi.fn().mockReturnValue([]),
});
container.register("NotificationQueue", mockNotificationQueue);
container.register("AgentOptions", {
callbacks: {},
});

const testAIManager = new AIManager(container, {
workdir: "/test/workdir",
stream: false,
});

// Spy on executeStopHooks to verify it is called
const stopHookSpy = vi
.spyOn(
testAIManager as unknown as {
executeStopHooks: () => Promise<boolean>;
},
"executeStopHooks",
)
.mockResolvedValue(false);

await testAIManager.sendAIMessage();

// Stop hooks should have been called despite pending notifications
expect(stopHookSpy).toHaveBeenCalled();
// Notifications should also have been dequeued
expect(mockNotificationQueue.dequeueAll).toHaveBeenCalled();
});
});

describe("Tool Call Partitioning and Serialization", () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-sdk/tests/utils/globalLogger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,8 @@ describe("Global Logger Registry", () => {
}
const end = performance.now();

// Should complete very quickly (less than 10ms for 4000 no-op calls)
expect(end - start).toBeLessThan(10);
// Should complete very quickly (less than 12ms for 4000 no-op calls)
expect(end - start).toBeLessThan(12);
expectNoLoggerCalls(mockLogger);
});

Expand Down