Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,6 @@ evals/logs/

# Git worktrees (ad-hoc local checkouts; keep out of git and test discovery)
.worktrees/

# ACP test logs (from scripts/test-acp-zed-bugs.mjs)
.acp-test-logs/
Comment on lines +106 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Missing .gitignore entry for the ACP logging proxy's log directory.

scripts/acp-logging-proxy.mjs writes full ACP traffic (prompts, tool output) to .acp-proxy-logs/ by default, but this diff only ignores .acp-test-logs/. Add the proxy's log directory to avoid accidentally committing captured session content.

🔧 Suggested fix
 # ACP test logs (from scripts/test-acp-zed-bugs.mjs)
 .acp-test-logs/
+
+# ACP proxy logs (from scripts/acp-logging-proxy.mjs)
+.acp-proxy-logs/
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# ACP test logs (from scripts/test-acp-zed-bugs.mjs)
.acp-test-logs/
# ACP test logs (from scripts/test-acp-zed-bugs.mjs)
.acp-test-logs/
# ACP proxy logs (from scripts/acp-logging-proxy.mjs)
.acp-proxy-logs/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 106 - 108, Add the ACP logging proxy’s default log
directory, .acp-proxy-logs/, to .gitignore alongside the existing
.acp-test-logs/ entry, preserving the current test-log ignore rule.

27 changes: 27 additions & 0 deletions packages/agents/src/core/agenticLoop/AgenticLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
buildToolResponses,
classifyCompletedTools,
recordCancelledToolHistory,
recordCompletedToolHistory,
} from './loopHelpers.js';

const logger = new DebugLogger('llxprt:agents:agentic-loop');
Expand Down Expand Up @@ -639,6 +640,17 @@ export class AgenticLoop {
await recordCancelledToolHistory(agentTools, this.agentClient);
return { continueLoop: false, nextMessage: [] };
}
// A successful pause request (the pause tool) is a terminal signal: the
// loop must stop so the agent returns control to the user. Without this,
// the loop feeds the pause response back to the model and continues
// indefinitely (issue #2653). In the CLI this is masked by the React UI
// continuation gate, but ACP/Zed and other headless consumers have none.
if (hasSuccessfulTodoPause(agentTools)) {
// Eagerly persist tool history since there will be no next model turn
// to carry the response parts naturally.
await recordCompletedToolHistory(agentTools, this.agentClient);
return { continueLoop: false, nextMessage: [] };
}
const responseParts = buildToolResponses(agentTools);
if (responseParts.length === 0) {
return { continueLoop: false, nextMessage: [] };
Expand All @@ -655,3 +667,18 @@ function* flushBuffered(queue: EventQueue): Generator<AgenticLoopEvent> {
event = queue.popBuffered();
}
}

/**
* Returns true if any of the completed tool calls is a successful pause
* (case-insensitive, no error). A failed pause must NOT terminate the loop,
* matching TodoContinuationService.isSuccessfulTodoPauseResponse semantics.
*/
function hasSuccessfulTodoPause(tools: CompletedToolCall[]): boolean {
return tools.some(
(tc) =>
tc.request.name.toLowerCase() === 'todo_pause' &&
tc.status === 'success' &&
tc.response.error === undefined &&
tc.response.errorType === undefined,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Vybestack LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Behavioral tests for AgenticLoop pause-loop-break (issue #2653).
*
* These tests verify that when the model calls the pause tool and the tool
* completes successfully, the AgenticLoop stops — it does NOT feed the
* pause response back into another model turn. This is critical for
* ACP/Zed and other headless consumers that lack the CLI's React UI
* continuation gate.
*
* The test approach follows dev-docs/RULES.md: real AgenticLoop, real
* CoreToolScheduler, real MessageBus. The only mock boundary is the
* provider stream (scripted ServerAgentStreamEvents).
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { AgenticLoop } from '../AgenticLoop.js';
import { MockTool } from '@vybestack/llxprt-code-core/test-utils/mock-tool.js';
import { clearAllSchedulers } from '@vybestack/llxprt-code-core/config/schedulerSingleton.js';
import { MessageBus } from '@vybestack/llxprt-code-core/confirmation-bus/message-bus.js';
import { ApprovalMode } from '@vybestack/llxprt-code-core/config/configTypes.js';
import {
createScriptedAgentClient,
createTestConfig,
createToolRegistryForTest,
createAskPolicyEngine,
collectEvents,
isToolsComplete,
toolCallRequestEvent,
finishedEvent,
} from './agenticLoop-test-helpers.js';

describe('AgenticLoop pause loop-break (issue #2653)', () => {
beforeEach(() => {
clearAllSchedulers();
});
afterEach(() => {
clearAllSchedulers();
});

it('stops the loop after a successful pause tool call (no extra model turn)', async () => {
const pauseTool = new MockTool({
name: 'todo_pause',
execute: async () => ({
llmContent: 'paused',
returnDisplay: 'paused',
}),
});

const toolRegistry = createToolRegistryForTest([pauseTool]);
const messageBus = new MessageBus(createAskPolicyEngine(), false);
const config = createTestConfig({
messageBus,
toolRegistry,
policyEngine: createAskPolicyEngine(),
interactive: true,
approvalMode: ApprovalMode.YOLO,
});

// Turn 1: model requests the pause tool, then finishes the turn.
// No Turn 2 script is provided — if the loop tries to continue, the
// scripted client will return an empty stream.
const { client, turnMessages } = createScriptedAgentClient([
[
toolCallRequestEvent('todo_pause', 'pause-1', {
reason: 'testing pause',
}),
finishedEvent(),
],
]);

const loop = new AgenticLoop({
agentClient: client,
config,
messageBus,
interactiveMode: true,
});

const events = await collectEvents(
loop,
[{ type: 'text', text: 'pause please' }],
new AbortController().signal,
);

expect(events.some(isToolsComplete)).toBe(true);
// CRITICAL: only ONE model turn should have happened. If the loop
// continued after the pause, turnMessages would have 2 entries.
expect(turnMessages).toHaveLength(1);

const errorEvents = events.filter((e) => e.kind === 'error');
expect(errorEvents).toHaveLength(0);
});

it('eagerly records pause tool history so the response is durable', async () => {
const pauseTool = new MockTool({
name: 'todo_pause',
execute: async () => ({
llmContent: 'paused with reason',
returnDisplay: 'paused',
}),
});

const toolRegistry = createToolRegistryForTest([pauseTool]);
const messageBus = new MessageBus(createAskPolicyEngine(), false);
const config = createTestConfig({
messageBus,
toolRegistry,
policyEngine: createAskPolicyEngine(),
interactive: true,
approvalMode: ApprovalMode.YOLO,
});

const { client, history } = createScriptedAgentClient([
[
toolCallRequestEvent('todo_pause', 'pause-2', {
reason: 'need to stop',
}),
finishedEvent(),
],
]);
Comment on lines +118 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test verifies that tool_response blocks exist in history, but it does not assert that the loop actually stopped after the pause tool call. If the implementation incorrectly continues to a second model turn after a successful pause, this test would still pass because the first turn's tool response would remain in history. Consider also destructuring turnMessages and asserting its length is 1, similar to the first test, to ensure the loop truly terminated.

Comment on lines +118 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test verifies that a tool_response block exists in history, but it does not confirm the loop actually terminated. If the implementation incorrectly continues to a second model turn after a successful pause, this test would still pass because the first turn's tool response remains in history. You already have turnMessages available from createScriptedAgentClient; add expect(turnMessages).toHaveLength(1) here to match the first test and ensure the loop truly stopped.


const loop = new AgenticLoop({
agentClient: client,
config,
messageBus,
interactiveMode: true,
});

await collectEvents(
loop,
[{ type: 'text', text: 'pause' }],
new AbortController().signal,
);

// History must contain the tool response blocks, even though there was
// no next model turn to carry them. This proves the eager history
// recording in buildNextMessage works.
const allBlocks = history.flatMap((h) => h.blocks);
const hasToolResponse = allBlocks.some((b) => b.type === 'tool_response');
expect(hasToolResponse).toBe(true);
});

it('does NOT stop the loop when the pause tool fails (error response)', async () => {
const pauseTool = new MockTool({
name: 'todo_pause',
execute: async () => {
throw new Error('pause failed');
},
});

const echoTool = new MockTool({
name: 'echo',
execute: async () => ({
llmContent: 'echoed',
returnDisplay: 'echoed',
}),
});

const toolRegistry = createToolRegistryForTest([pauseTool, echoTool]);
const messageBus = new MessageBus(createAskPolicyEngine(), false);
const config = createTestConfig({
messageBus,
toolRegistry,
policyEngine: createAskPolicyEngine(),
interactive: true,
approvalMode: ApprovalMode.YOLO,
});

const { client, turnMessages } = createScriptedAgentClient([
[
toolCallRequestEvent('todo_pause', 'pause-err', {
reason: 'will fail',
}),
finishedEvent(),
],
[toolCallRequestEvent('echo', 'echo-1', {}), finishedEvent()],
]);

const loop = new AgenticLoop({
agentClient: client,
config,
messageBus,
interactiveMode: true,
});

await collectEvents(
loop,
[{ type: 'text', text: 'try pause then echo' }],
new AbortController().signal,
);

// The failed pause must NOT have terminated the loop — the model should
// have received the error and continued with a second turn.
expect(turnMessages.length).toBeGreaterThanOrEqual(2);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This assertion is too loose. With exactly 2 scripted turns, use toBe(2) instead of toBeGreaterThanOrEqual(2) to catch regressions where the loop continues beyond the expected second turn.

Comment on lines +197 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test verifies that the loop continues after a failed pause tool, but it only checks the number of turns (turnMessages.length &gt;= 2). It does not verify that the error from the failed pause tool was actually propagated to the model in the next turn. Consider inspecting the second turn's messages to confirm they include the error result from the failed todo_pause execution, ensuring errors are not silently swallowed.

Comment on lines +197 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test verifies the loop continues after a failed pause tool, but it only checks turn count (turnMessages.length &gt;= 2). It does not validate that the error from the failed todo_pause execution was actually propagated to the model in the next turn. Inspect the second turn's messages to confirm they include the error result, ensuring errors are not silently swallowed.

});

it('continues the loop normally for non-pause tools after the fix', async () => {
// Regression guard: a normal successful tool should still cause the
// loop to continue (feed response, next model turn).
const normalTool = new MockTool({
name: 'get_info',
execute: async () => ({
llmContent: 'info result',
returnDisplay: 'info result',
}),
});

const toolRegistry = createToolRegistryForTest([normalTool]);
const messageBus = new MessageBus(createAskPolicyEngine(), false);
const config = createTestConfig({
messageBus,
toolRegistry,
policyEngine: createAskPolicyEngine(),
interactive: true,
approvalMode: ApprovalMode.YOLO,
});

const { client, turnMessages } = createScriptedAgentClient([
[toolCallRequestEvent('get_info', 'info-1', {}), finishedEvent()],
[finishedEvent()],
]);

const loop = new AgenticLoop({
agentClient: client,
config,
messageBus,
interactiveMode: true,
});

await collectEvents(
loop,
[{ type: 'text', text: 'get info' }],
new AbortController().signal,
);

expect(turnMessages).toHaveLength(2);
});
});
2 changes: 1 addition & 1 deletion packages/agents/src/core/agenticLoop/loopHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function buildToolResponses(
* Awaits both writes so callers that continue or exit the loop immediately
* afterwards can guarantee the tool history is durable before the next turn.
*/
async function recordCompletedToolHistory(
export async function recordCompletedToolHistory(
tools: CompletedToolCall[],
agentClient: AgentClientContract,
): Promise<void> {
Expand Down
Loading
Loading