Skip to content

Commit 005ba03

Browse files
committed
fix: improve thread resume dedupe
1 parent 215a532 commit 005ba03

File tree

17 files changed

+2177
-0
lines changed

17 files changed

+2177
-0
lines changed

package-lock.json

Lines changed: 732 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
},
4141
"devDependencies": {
4242
"@tauri-apps/cli": "^2",
43+
"@testing-library/react": "^16.3.2",
4344
"@types/prismjs": "^1.26.5",
4445
"@types/react": "^19.1.8",
4546
"@types/react-dom": "^19.1.6",
@@ -49,6 +50,7 @@
4950
"eslint": "^8.57.0",
5051
"eslint-plugin-react": "^7.36.1",
5152
"eslint-plugin-react-hooks": "^4.6.2",
53+
"jsdom": "^27.0.1",
5254
"typescript": "~5.8.3",
5355
"vite": "^7.0.4",
5456
"vitest": "^3.2.4"
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// @vitest-environment jsdom
2+
import { act } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { AppServerEvent } from "../../../types";
6+
import { subscribeAppServerEvents } from "../../../services/events";
7+
import { useAppServerEvents } from "./useAppServerEvents";
8+
9+
vi.mock("../../../services/events", () => ({
10+
subscribeAppServerEvents: vi.fn(),
11+
}));
12+
13+
type Handlers = Parameters<typeof useAppServerEvents>[0];
14+
15+
function TestHarness({ handlers }: { handlers: Handlers }) {
16+
useAppServerEvents(handlers);
17+
return null;
18+
}
19+
20+
let listener: ((event: AppServerEvent) => void) | null = null;
21+
const unlisten = vi.fn();
22+
23+
beforeEach(() => {
24+
listener = null;
25+
unlisten.mockReset();
26+
vi.mocked(subscribeAppServerEvents).mockImplementation(async (cb) => {
27+
listener = cb;
28+
return unlisten;
29+
});
30+
});
31+
32+
afterEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
async function mount(handlers: Handlers) {
37+
const container = document.createElement("div");
38+
const root = createRoot(container);
39+
await act(async () => {
40+
root.render(<TestHarness handlers={handlers} />);
41+
});
42+
return { root };
43+
}
44+
45+
describe("useAppServerEvents", () => {
46+
it("routes app-server events to handlers", async () => {
47+
const handlers: Handlers = {
48+
onAppServerEvent: vi.fn(),
49+
onWorkspaceConnected: vi.fn(),
50+
onAgentMessageDelta: vi.fn(),
51+
onApprovalRequest: vi.fn(),
52+
onItemCompleted: vi.fn(),
53+
onAgentMessageCompleted: vi.fn(),
54+
};
55+
const { root } = await mount(handlers);
56+
57+
expect(listener).toBeTypeOf("function");
58+
59+
act(() => {
60+
listener?.({ workspace_id: "ws-1", message: { method: "codex/connected" } });
61+
});
62+
expect(handlers.onWorkspaceConnected).toHaveBeenCalledWith("ws-1");
63+
64+
act(() => {
65+
listener?.({
66+
workspace_id: "ws-1",
67+
message: {
68+
method: "item/agentMessage/delta",
69+
params: { threadId: "thread-1", itemId: "item-1", delta: "Hello" },
70+
},
71+
});
72+
});
73+
expect(handlers.onAgentMessageDelta).toHaveBeenCalledWith({
74+
workspaceId: "ws-1",
75+
threadId: "thread-1",
76+
itemId: "item-1",
77+
delta: "Hello",
78+
});
79+
80+
act(() => {
81+
listener?.({
82+
workspace_id: "ws-1",
83+
message: {
84+
method: "workspace/requestApproval",
85+
id: 7,
86+
params: { mode: "full" },
87+
},
88+
});
89+
});
90+
expect(handlers.onApprovalRequest).toHaveBeenCalledWith({
91+
workspace_id: "ws-1",
92+
request_id: 7,
93+
method: "workspace/requestApproval",
94+
params: { mode: "full" },
95+
});
96+
97+
act(() => {
98+
listener?.({
99+
workspace_id: "ws-1",
100+
message: {
101+
method: "item/completed",
102+
params: {
103+
threadId: "thread-1",
104+
item: { type: "agentMessage", id: "item-2", text: "Done" },
105+
},
106+
},
107+
});
108+
});
109+
expect(handlers.onItemCompleted).toHaveBeenCalledWith("ws-1", "thread-1", {
110+
type: "agentMessage",
111+
id: "item-2",
112+
text: "Done",
113+
});
114+
expect(handlers.onAgentMessageCompleted).toHaveBeenCalledWith({
115+
workspaceId: "ws-1",
116+
threadId: "thread-1",
117+
itemId: "item-2",
118+
text: "Done",
119+
});
120+
121+
await act(async () => {
122+
root.unmount();
123+
});
124+
expect(unlisten).toHaveBeenCalledTimes(1);
125+
});
126+
127+
it("ignores delta events missing required fields", async () => {
128+
const handlers: Handlers = {
129+
onAgentMessageDelta: vi.fn(),
130+
};
131+
const { root } = await mount(handlers);
132+
133+
act(() => {
134+
listener?.({
135+
workspace_id: "ws-1",
136+
message: {
137+
method: "item/agentMessage/delta",
138+
params: { threadId: "", itemId: "item-1", delta: "Hello" },
139+
},
140+
});
141+
});
142+
act(() => {
143+
listener?.({
144+
workspace_id: "ws-1",
145+
message: {
146+
method: "item/agentMessage/delta",
147+
params: { threadId: "thread-1", itemId: "", delta: "Hello" },
148+
},
149+
});
150+
});
151+
act(() => {
152+
listener?.({
153+
workspace_id: "ws-1",
154+
message: {
155+
method: "item/agentMessage/delta",
156+
params: { threadId: "thread-1", itemId: "item-1", delta: "" },
157+
},
158+
});
159+
});
160+
161+
expect(handlers.onAgentMessageDelta).not.toHaveBeenCalled();
162+
163+
await act(async () => {
164+
root.unmount();
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)