Skip to content

Commit 6f53e89

Browse files
authored
Handle local agent connection recovery (#2853)
Handle connection-drop and retry behavior in local agent IPC handling. Add an end-to-end scenario that verifies recovery after a temporary local agent disconnect. Align test fixtures and snapshots for local-agent reconnection behavior.
1 parent 709222c commit 6f53e89

12 files changed

+1190
-292
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ dist/
1212
# playwright
1313
playwright-report/
1414
test-results/
15+
blob-report/
16+
flakiness-report/
1517

1618
# Diagnostic reports (https://nodejs.org/api/report.html)
1719
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
2+
3+
/**
4+
* Tests retry behavior when connection drops after tool-call chunks were emitted
5+
* but before the stream is finalized. This simulates an orphaned tool-call retry
6+
* window and ensures we don't duplicate tool execution.
7+
*/
8+
export const fixture: LocalAgentFixture = {
9+
description: "Connection drop after streaming tool-call chunks",
10+
dropConnectionAfterToolCallByTurn: [{ turnIndex: 0, attempts: [1] }],
11+
turns: [
12+
{
13+
text: "I'll create a file for you.",
14+
toolCalls: [
15+
{
16+
name: "write_file",
17+
args: {
18+
path: "src/recovered-after-tool-call.ts",
19+
content: `export const recoveredAfterToolCall = true;\n`,
20+
description: "File created after tool-call termination recovery",
21+
},
22+
},
23+
],
24+
},
25+
{
26+
text: "Successfully created the file after retrying from a tool-call termination.",
27+
},
28+
],
29+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
2+
3+
/**
4+
* Tests automatic retry after connection drop (e.g., TCP terminated mid-stream).
5+
* This fixture drops the connection on the first attempt of turn 1 (the
6+
* post-tool text turn), which is more realistic than dropping before any
7+
* tool activity. The local agent handler should automatically retry and
8+
* continue without re-running completed work.
9+
*/
10+
export const fixture: LocalAgentFixture = {
11+
description: "Automatic retry after connection drop",
12+
dropConnectionByTurn: [{ turnIndex: 1, attempts: [1] }],
13+
turns: [
14+
{
15+
text: "I'll create a file for you.",
16+
toolCalls: [
17+
{
18+
name: "write_file",
19+
args: {
20+
path: "src/recovered.ts",
21+
content: `export const recovered = true;\n`,
22+
description: "File created after connection recovery",
23+
},
24+
},
25+
],
26+
},
27+
{
28+
text: "Successfully created the file after automatic retry.",
29+
},
30+
],
31+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { expect } from "@playwright/test";
2+
import { testSkipIfWindows } from "./helpers/test_helper";
3+
4+
/**
5+
* E2E test for local-agent connection retry resilience.
6+
* Verifies that the agent automatically recovers from transient connection
7+
* drops (e.g., TCP terminated mid-stream) by retrying the stream.
8+
*/
9+
10+
testSkipIfWindows(
11+
"local-agent - recovers from connection drop",
12+
async ({ po }) => {
13+
await po.setUpDyadPro({ localAgent: true });
14+
await po.importApp("minimal");
15+
await po.chatActions.selectLocalAgentMode();
16+
17+
// The connection-drop fixture drops on turn 1 (after a tool turn already
18+
// completed) to simulate a realistic interrupted follow-up request.
19+
await po.sendPrompt("tc=local-agent/connection-drop");
20+
21+
// Verify the turn still completed and no error box leaked to the UI.
22+
await expect(po.page.getByTestId("chat-error-box")).toHaveCount(0);
23+
const introText = po.page.getByText("I'll create a file for you.");
24+
const completionText = po.page.getByText(
25+
"Successfully created the file after automatic retry.",
26+
);
27+
await expect(introText).toHaveCount(1);
28+
await expect(completionText).toHaveCount(1);
29+
await expect(introText).toBeVisible();
30+
await expect(completionText).toBeVisible();
31+
// Partial chunks from the dropped attempt must not leak into final UI.
32+
await expect(
33+
po.page.getByText("Partial response before connection dr"),
34+
).toHaveCount(0);
35+
36+
// Verify exactly one recovered.ts edit card is shown in chat.
37+
const recoveredEditCard = po.page.getByRole("button", {
38+
name: /recovered\.ts .*src\/recovered\.ts.*Edit/,
39+
});
40+
await expect(recoveredEditCard).toHaveCount(1);
41+
42+
// The replayed conversation order must stay:
43+
// intro assistant text -> tool edit card -> completion assistant text.
44+
const introY = (await introText.boundingBox())?.y;
45+
const editCardY = (await recoveredEditCard.boundingBox())?.y;
46+
const completionY = (await completionText.boundingBox())?.y;
47+
expect(introY).toBeDefined();
48+
expect(editCardY).toBeDefined();
49+
expect(completionY).toBeDefined();
50+
expect(introY!).toBeLessThan(editCardY!);
51+
expect(editCardY!).toBeLessThan(completionY!);
52+
53+
// Snapshot end state for chat + filesystem.
54+
await po.snapshotMessages();
55+
await po.snapshotAppFiles({
56+
name: "after-connection-retry",
57+
files: ["src/recovered.ts"],
58+
});
59+
},
60+
);
61+
62+
testSkipIfWindows(
63+
"local-agent - recovers when drop happens after tool-call stream",
64+
async ({ po }) => {
65+
await po.setUpDyadPro({ localAgent: true });
66+
await po.importApp("minimal");
67+
await po.chatActions.selectLocalAgentMode();
68+
69+
await po.sendPrompt("tc=local-agent/connection-drop-after-tool-call");
70+
71+
await expect(po.page.getByTestId("chat-error-box")).toHaveCount(0);
72+
await expect(
73+
po.page.getByText(
74+
"Successfully created the file after retrying from a tool-call termination.",
75+
),
76+
).toBeVisible();
77+
78+
await expect(
79+
po.page
80+
.getByRole("button", {
81+
name: /recovered-after-tool-call\.ts .*src\/recovered-after-tool-call\.ts.*Edit/,
82+
})
83+
.first(),
84+
).toBeVisible();
85+
86+
await po.snapshotAppFiles({
87+
name: "after-tool-call-connection-retry",
88+
files: ["src/recovered-after-tool-call.ts"],
89+
});
90+
},
91+
);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
=== src/recovered.ts ===
2+
export const recovered = true;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
=== src/recovered-after-tool-call.ts ===
2+
export const recoveredAfterToolCall = true;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
2+
- button "file1.txt file1.txt Edit":
3+
- img
4+
- text: ""
5+
- button "Edit":
6+
- img
7+
- text: ""
8+
- img
9+
- paragraph: More EOM
10+
- button "Copy":
11+
- img
12+
- img
13+
- text: Approved
14+
- img
15+
- text: claude-opus-4-5
16+
- img
17+
- text: less than a minute ago
18+
- img
19+
- text: (1 files changed)
20+
- button "Copy Request ID":
21+
- img
22+
- text: ""
23+
- paragraph: tc=local-agent/connection-drop
24+
- paragraph: I'll create a file for you.
25+
- 'button "recovered.ts src/recovered.ts Edit Summary: File created after connection recovery"':
26+
- img
27+
- text: ""
28+
- button "Edit":
29+
- img
30+
- text: ""
31+
- img
32+
- text: ""
33+
- paragraph: Successfully created the file after automatic retry.
34+
- button "Copy":
35+
- img
36+
- img
37+
- text: claude-opus-4-5
38+
- img
39+
- text: less than a minute ago
40+
- button "Copy Request ID":
41+
- img
42+
- text: ""
43+
- button "Undo":
44+
- img
45+
- text: ""
46+
- button "Retry":
47+
- img
48+
- text: ""

0 commit comments

Comments
 (0)